Source code for uclchem.advanced.advanced_settings

"""General settings interface for UCLCHEM Fortran modules.

This module provides class-based interfaces for accessing and modifying runtime
settings across all UCLCHEM Fortran modules:

- Setting: Represents a single setting with metadata and edit tracking
- ModuleSettings: Container for all settings in a Fortran module
- GeneralSettings: Top-level interface to all modules

**Thread Safety Warning:**
All classes in this module modify global Fortran module state and are **NOT thread-safe**.
Do not use with multiprocessing, multithreading, or concurrent model runs.
Settings should only be modified during initialization, before running models.

Note: Changes made through these classes affect the global Fortran state and persist
across model runs in the same Python session.

"""

from __future__ import annotations

import logging
import warnings
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any

import numpy as np
import uclchemwrap

# Import parameter classifications from constants module
from .constants import FILE_PATH_PARAMETERS, FORTRAN_PARAMETERS, INTERNAL_PARAMETERS

if TYPE_CHECKING:
    from collections.abc import Iterator
    from types import ModuleType

[docs] logger = logging.getLogger(__name__)
def _copy_value(value: Any) -> Any: """Make a copy of a value (handle arrays and scalars). Parameters ---------- value : Any value to be copied. Returns ------- Any copy of the original object """ if isinstance(value, np.ndarray): return value.copy() else: return value
[docs] class Setting: """Represents a single runtime setting from a Fortran module. Tracks the current value, edit status, default value, and metadata for a Fortran module variable. Attributes ---------- name : str Setting name module_name : str Parent Fortran module name current_value : Any Current value (cached on last read) is_edited : bool Whether value has been modified from default default_value : bool Original default value at initialization dtype : npt.DTypeLike NumPy dtype or Python type is_parameter : bool True if this is a compile-time constant (read-only) is_internal : bool True if this is an internal solver parameter is_file_path : bool True if this is a file path (should use param_dict) shape : tuple[int, int] | None Array shape (None for scalars) """ def __init__( self, name: str, module_name: str, fortran_module: ModuleType, is_parameter: bool = False, is_internal: bool = False, is_file_path: bool = False, ): """Initialize a Setting object. Parameters ---------- name : str Setting name module_name : str Parent module name fortran_module : ModuleType Reference to the Fortran module is_parameter : bool Whether this is a PARAMETER (read-only). Default = False. is_internal : bool Whether this is an internal solver parameter. Default = False. is_file_path : bool Whether this is a file path parameter (should use param_dict). Default = False. """
[docs] self.name = name
[docs] self.module_name = module_name
self._fortran_module = fortran_module
[docs] self.is_parameter = is_parameter
[docs] self.is_internal = is_internal
[docs] self.is_file_path = is_file_path
# Read initial state value = getattr(fortran_module, name)
[docs] self.default_value = _copy_value(value)
[docs] self.current_value = _copy_value(value)
[docs] self.is_edited = False
# Store metadata if isinstance(value, np.ndarray) and value.shape: self.dtype = value.dtype self.shape = value.shape else: self.dtype = type(value) self.shape = None
[docs] def get(self, check_memory: bool = True) -> float | int | np.ndarray: """Get the current value of the setting. Parameters ---------- check_memory : bool If True, compare cached value with actual Fortran memory and warn if they differ. Default = True. Returns ------- float | int | np.ndarray Current value from Fortran memory """ memory_value = getattr(self._fortran_module, self.name) if check_memory: # Compare with cached value if isinstance(memory_value, np.ndarray) and memory_value.shape: if not np.array_equal(memory_value, self.current_value): warnings.warn( f"{self.module_name}.{self.name} has been modified " f"outside of GeneralSettings (cache out of sync)", UserWarning, stacklevel=2, ) elif memory_value != self.current_value: warnings.warn( f"{self.module_name}.{self.name} has been modified " f"outside of GeneralSettings (cache out of sync)", UserWarning, stacklevel=2, ) # Update cache self.current_value = _copy_value(memory_value) return memory_value
[docs] def set(self, value: float | int | np.ndarray) -> None: """Set the value of the setting. Parameters ---------- value : float | int | np.ndarray New value to set Raises ------ RuntimeError If attempting to modify a PARAMETER or file path parameter """ if self.is_parameter: msg = ( f"Cannot modify {self.module_name}.{self.name}: " f"it is a Fortran PARAMETER (compile-time constant). " f"To change this value, you must edit the Fortran source, " f"regenerate with MakeRates, and rebuild." ) raise RuntimeError(msg) if self.is_file_path: msg = ( f"Cannot modify {self.module_name}.{self.name}: " f"file paths should be set via param_dict when calling model functions " f"(e.g., uclchem.model.cloud(param_dict={{'outputFile': '...'}})). " f"File I/O paths are handled specially by the model wrapper and parsers." ) raise RuntimeError(msg) # Set the value in Fortran memory setattr(self._fortran_module, self.name, value) # Update cache and edit status self.current_value = _copy_value(value) self.is_edited = ( not np.array_equal(self.current_value, self.default_value) if isinstance(self.current_value, np.ndarray) else (self.current_value != self.default_value) )
[docs] def reset(self) -> None: """Reset the setting to its default value. Raises ------ RuntimeError If the setting is a Fortran parameter """ if self.is_parameter: msg = ( f"Cannot reset {self.module_name}.{self.name}: " f"it is a Fortran PARAMETER (compile-time constant)" ) raise RuntimeError(msg) self.set(self.default_value) self.is_edited = False
def __repr__(self) -> str: """Get a string representation of the setting. Returns ------- str String representation of the setting. """ status = [] if self.is_parameter: status.append("PARAMETER") if self.is_internal: status.append("INTERNAL") if self.is_file_path: status.append("FILE_PATH") if self.is_edited: status.append("EDITED") status_str = f" [{', '.join(status)}]" if status else "" if isinstance(self.current_value, np.ndarray): # For small arrays, show values; for large ones, just shape if self.current_value.size <= 3: # noqa: PLR2004 value_str = str(self.current_value) else: value_str = f"<array shape={self.shape}, dtype={self.dtype}>" else: value_str = str(self.current_value) return f"Setting({self.module_name}.{self.name} = {value_str}{status_str})"
[docs] class ModuleSettings: """Container for all settings from a single Fortran module. Provides dict-like access to Setting objects with attribute-style syntax. """ def __init__( self, module_name: str, fortran_module: ModuleType, parameter_names: set[str], internal_names: set[str], file_path_names: set[str], ): """Initialize settings for a module. Parameters ---------- module_name : str Name of the Fortran module fortran_module : ModuleType Reference to the actual Fortran module parameter_names : set[str] Set of names that are PARAMETERs internal_names : set[str] Set of names that are internal solver parameters file_path_names : set[str] Set of names that are file paths (should use param_dict) """
[docs] self.module_name = module_name
self._fortran_module = fortran_module self._settings = {} # Discover all attributes attrs = [a for a in dir(fortran_module) if not a.startswith("_")] for attr in attrs: try: value = getattr(fortran_module, attr) if callable(value): continue # Determine classification is_param = attr.lower() in parameter_names is_internal = attr.lower() in internal_names is_file_path = attr.lower() in file_path_names # Create Setting object setting = Setting( attr, module_name, fortran_module, is_param, is_internal, is_file_path, ) self._settings[attr.lower()] = setting except Exception as e: # Skip attributes that can't be accessed logger.exception( f"Exception occurred when accessing attribute '{attr}' from module '{module_name}':\n", e, ) def __getattr__(self, name: str) -> Setting: """Get a Setting object by name. Parameters ---------- name : str name of setting. Returns ------- Setting setting Raises ------ AttributeError if no setting with `name` is available. """ if name.startswith("_"): return object.__getattribute__(self, name) name_lower = name.lower() if name_lower in self._settings: return self._settings[name_lower] msg = ( f"Module '{self.module_name}' has no setting '{name}'. " f"Available: {', '.join(list(self._settings.keys())[:10])}..." ) raise AttributeError(msg) def __setattr__(self, name: str, value: Any) -> None: """Set a setting value. Parameters ---------- name : str name of setting. value : Any value to set setting to. Raises ------ AttributeError if no setting with `name` is available. """ logger.debug(f"Trying to set setting '{name}' to {value}") if name.startswith("_") or name == "module_name": object.__setattr__(self, name, value) return name_lower = name.lower() if name_lower in self._settings: self._settings[name_lower].set(value) else: msg = f"Module '{self.module_name}' has no setting '{name}'" raise AttributeError(msg) def __dir__(self) -> list[str]: """List all settings. Returns ------- list[str] List of all settings. """ return list(self._settings.keys())
[docs] def list_settings( self, include_internal: bool = False, include_parameters: bool = False ) -> dict[str, Setting]: """List settings with their current values. Parameters ---------- include_internal : bool Include internal solver parameters. Default = False. include_parameters : bool Include read-only PARAMETERs. Default = False. Returns ------- dict[str, Setting] Dict mapping setting names to Setting objects """ result = {} for name, setting in self._settings.items(): if not include_internal and setting.is_internal: continue if not include_parameters and setting.is_parameter: continue result[name] = setting return result
[docs] def print_settings( self, include_internal: bool = False, include_parameters: bool = False ) -> None: """Print all settings in a readable format. Parameters ---------- include_internal : bool Include internal solver parameters. Default = False. include_parameters : bool Include read-only PARAMETERs. Default = False. """ print(f"\n{'=' * 70}") print(f"Module: {self.module_name}") print(f"{'=' * 70}\n") settings = self.list_settings(include_internal, include_parameters) for name in sorted(settings.keys()): setting = settings[name] flags = [] if setting.is_parameter: flags.append("PARAM") if setting.is_internal: flags.append("INTERNAL") if setting.is_file_path: flags.append("FILE_PATH") if setting.is_edited: flags.append("EDITED") flag_str = f" [{','.join(flags)}]" if flags else "" if isinstance(setting.current_value, np.ndarray): if setting.current_value.size <= 5: # noqa: PLR2004 value_str = str(setting.current_value) else: value_str = f"<array shape={setting.shape}>" else: value_str = str(setting.current_value) print(f" {name:35s} = {value_str}{flag_str}") print()
[docs] class GeneralSettings: """General interface to all UCLCHEM settings across all Fortran modules. Provides dynamic access to modifiable parameters in any uclchemwrap module, with automatic detection of read-only PARAMETERs and internal solver settings. Each setting is represented by a Setting object that tracks: - Current value (with caching) - Edit status - Default value - Metadata (dtype, shape, flags) **Thread Safety Warning:** This class modifies global Fortran module state and is **NOT thread-safe**. Do not use with multiprocessing or concurrent model runs. Settings should only be modified during initialization, before running models. Usage: >>> from uclchem.advanced.advanced_settings import GeneralSettings >>> settings = GeneralSettings() >>> >>> # Access a setting >>> setting = settings.defaultparameters.initialdens >>> print(setting.get()) 100.0 >>> >>> # Modify a setting >>> setting.set(500.0) >>> >>> # Get the modified value >>> print(setting.get()) 500.0 >>> >>> # Or use attribute-style syntax >>> settings.defaultparameters.initialdens = 500.0 >>> >>> # List all settings in a module >>> settings.defaultparameters.print_settings() # doctest: +SKIP ... >>> >>> # Use context manager for temporary changes >>> with settings.temporary_changes(): ... settings.defaultparameters.initialdens = 1000.0 ... print(setting.get()) ... # Run model with modified settings ... 1000.0 >>> # Settings automatically restored after context >>> print(setting.get()) 500.0 >>> # As well as in the GeneralSettings instance >>> print(settings.defaultparameters.initialdens) Setting(defaultparameters.initialdens = 500.0 [EDITED]) >>> >>> # Reset to the original value >>> setting.set(100.0) """ def __init__(self): """Initialize with all available uclchemwrap modules.""" self._modules = {} self._discover_modules() def _discover_modules(self): """Discover all available modules in uclchemwrap.""" # Known module names module_names = [ "defaultparameters", "network", "heating", "physicscore", "constants", "cloud_mod", "collapse_mod", "cshock_mod", "jshock_mod", "hotcore", "chemistry", "rates", "photoreactions", "surfacereactions", "io", "f2py_constants", "postprocess_mod", "sputtering", ] for name in module_names: if hasattr(uclchemwrap, name): module = getattr(uclchemwrap, name) logger.debug(f"Creating ModuleSettings for module '{name}'") self._modules[name] = ModuleSettings( name, module, FORTRAN_PARAMETERS, INTERNAL_PARAMETERS, FILE_PATH_PARAMETERS, ) def __getattr__(self, name: str) -> ModuleType: """Access modules as attributes. Parameters ---------- name : str Module name Returns ------- ModuleType module with name `name`. Raises ------ AttributeError If no module with name `name` available. """ if name.startswith("_"): return object.__getattribute__(self, name) if name in self._modules: return self._modules[name] msg = ( f"No module '{name}' available.\n" f"Available modules: {', '.join(sorted(self._modules.keys()))}" ) raise AttributeError(msg) def __dir__(self) -> list[str]: """List all available modules. Returns ------- list[str] List of all available modules. """ return list(self._modules.keys())
[docs] def list_modules(self) -> None: """List all available modules with statistics.""" print(f"\n{'=' * 70}") print("Available UCLCHEM Modules") print(f"{'=' * 70}\n") for name in sorted(self._modules.keys()): mod_settings = self._modules[name] all_settings = mod_settings._settings n_user = sum( 1 for s in all_settings.values() if not s.is_parameter and not s.is_internal ) n_param = sum(1 for s in all_settings.values() if s.is_parameter) n_internal = sum(1 for s in all_settings.values() if s.is_internal) print( f" {name:25s} {n_user:3d} user settings, " f"{n_param:3d} parameters, {n_internal:3d} internal" ) print()
[docs] def search( self, pattern: str, include_internal: bool = False, include_parameters: bool = False, ) -> dict[str, Setting]: """Search for settings matching a pattern across all modules. Parameters ---------- pattern : str String pattern to search for (case-insensitive). include_internal : bool Include internal solver parameters. Default = False. include_parameters : bool Include read-only PARAMETERs. Default = False. Returns ------- dict[str, Setting] Dict mapping "module.setting" to Setting objects """ pattern = pattern.lower() results = {} for module_name, mod_settings in self._modules.items(): for setting_name, setting in mod_settings._settings.items(): if not include_internal and setting.is_internal: continue if not include_parameters and setting.is_parameter: continue if pattern in setting_name: full_name = f"{module_name}.{setting_name}" results[full_name] = setting return results
[docs] def print_all_edited(self) -> None: """Print all settings that have been modified from defaults.""" print(f"\n{'=' * 70}") print("Modified Settings") print(f"{'=' * 70}\n") found_any = False for module_name in sorted(self._modules.keys()): mod_settings = self._modules[module_name] edited = { name: s for name, s in mod_settings._settings.items() if s.is_edited } if edited: found_any = True print(f"\n{module_name}:") for name in sorted(edited.keys()): setting = edited[name] print( f" {name:35s} {setting.default_value}{setting.current_value}" ) if not found_any: print(" No settings have been modified") print()
[docs] def print_all_settings(self) -> None: """Print all settings across all modules.""" for module_name in sorted(self._modules.keys()): mod_settings = self._modules[module_name] mod_settings.print_settings(include_internal=True, include_parameters=True)
[docs] def reset_all(self, confirm: bool = True) -> None: """Reset all settings to their default values. Parameters ---------- confirm : bool If True, require user confirmation. Default = True. """ if confirm: response = input("Reset ALL settings to defaults? (yes/no): ") logger.debug(f"Response: {response}") if response.lower() != "yes": print("Reset cancelled") return logger.debug("Resetting all settings to their default values") for mod_settings in self._modules.values(): for setting in mod_settings._settings.values(): if not setting.is_parameter and setting.is_edited: try: setting.reset() except Exception as e: print(f"Warning: Could not reset {setting.name}: {e}") logger.debug("All settings reset to defaults")
@contextmanager
[docs] def temporary_changes(self) -> Iterator[GeneralSettings]: """Context manager for temporary setting modifications. Saves current state on entry and restores it on exit, even if an exception occurs. Useful for running models with temporary parameter changes without affecting the global state. Yields ------ self : GeneralSettings The GeneralSettings instance for chaining Examples -------- >>> settings = GeneralSettings() >>> settings.defaultparameters.initialdens = 100.0 >>> print(settings.defaultparameters.initialdens.get()) 100.0 >>> >>> with settings.temporary_changes(): ... settings.defaultparameters.initialdens = 5000.0 ... print(settings.defaultparameters.initialdens.get()) ... # Run model here ... 5000.0 >>> >>> print(settings.defaultparameters.initialdens.get()) 100.0 """ # Save all current values (not just edited ones) # Skip PARAMETERs (compile-time constants), INTERNAL_PARAMETERS (solver state), and arrays saved_states: dict[str, dict[str, Any]] = {} for module_name, mod_settings in self._modules.items(): saved_states[module_name] = {} for setting_name, setting in mod_settings._settings.items(): # Skip PARAMETERs (can't be modified), internal parameters (unsafe to restore), # and arrays (Fortran memory management issues) if ( setting.is_parameter or setting.is_internal or setting.shape is not None ): continue try: saved_states[module_name][setting_name] = { "value": _copy_value(setting.get(check_memory=False)), "is_edited": setting.is_edited, } except Exception as e: # Warn about settings that can't be safely saved warnings.warn( f"Could not save {module_name}.{setting_name} for temporary_changes: {e}", UserWarning, stacklevel=2, ) try: yield self finally: # Restore all values for module_name, settings_dict in saved_states.items(): mod_settings = self._modules[module_name] for setting_name, state in settings_dict.items(): setting = mod_settings._settings[setting_name] if setting.get() == state["value"]: # Setting was not edited, no need to reset it. continue try: setting.set(state["value"]) setting.is_edited = state["is_edited"] except Exception as e: warnings.warn( f"Could not restore {module_name}.{setting_name}: {e}", UserWarning, stacklevel=2, )