Source code for uclchem.advanced.advanced_network

"""Runtime access to UCLCHEM's compiled chemical network state.

This module provides a class-based interface for accessing and modifying the chemical
network that is compiled into the Fortran code. Unlike the Network class in makerates
(used for building networks), NetworkState provides access to the active, in-memory
network during model execution.

RuntimeSpecies and RuntimeReaction provide APIs similar to the Species and Reaction
classes in makerates, but wrap the compiled Fortran data rather than generating new code.

**Thread Safety Warning:**
NetworkState modifies global Fortran module state and is **NOT thread-safe**.
Do not use with multiprocessing, multithreading, or concurrent model runs.

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

"""

import warnings
from types import ModuleType

import numpy as np
from uclchemwrap import network as network_module

from uclchem.makerates.reaction import Reaction, reaction_header
from uclchem.makerates.species import Species
from uclchem.utils import UCLCHEM_ROOT_DIR, get_reaction_table, get_species_table


[docs] class RuntimeSpecies: """Wrapper for a species in the compiled network. Provides a similar API to makerates.Species but accesses the compiled Fortran data. """ def __init__(self, index: int, network_ref: ModuleType): """Initialize a runtime species wrapper. Parameters ---------- index : int 1-based species index in Fortran arrays network_ref : ModuleType Reference to the network module """ self._index = index self._network = network_ref self._array_idx = index - 1 # 0-based for array access
[docs] def get_name(self) -> str: """Get the species name. Returns ------- str Species name """ return str(np.char.decode(self._network.specname[self._array_idx])).strip()
[docs] def get_mass(self) -> float: """Get the molecular mass. Returns ------- float Mass in atomic mass units """ return float(self._network.mass[self._array_idx])
[docs] def get_binding_energy(self) -> float | None: """Get the binding energy. Returns ------- float | None Binding energy in Kelvin (or None if not available) """ if self._array_idx < len(self._network.bindingenergy): return float(self._network.bindingenergy[self._array_idx]) return None
[docs] def get_enthalpy(self) -> float | None: """Get the formation enthalpy. Returns ------- float | None Formation enthalpy in kJ/mol (or None if not available) """ if self._array_idx < len(self._network.formationenthalpy): return float(self._network.formationenthalpy[self._array_idx]) return None
[docs] def is_ice_species(self) -> bool: """Return whether the species is a species on the grain. Returns ------- bool True if it is an ice species. """ return ( self.get_name() in {"BULK", "SURFACE"} or self.get_name().startswith( "#", ) or self.get_name().startswith("@") )
[docs] def is_ion(self) -> bool: """Check if this is an ion. Returns ------- bool True if species name contains + or - """ name = self.get_name() return "+" in name or "-" in name
[docs] def get_charge(self) -> int: """Get the charge of the species. Returns ------- int Charge (+1, -1, or 0) """ name = self.get_name() if "+" in name: return 1 elif "-" in name: return -1 return 0
def __str__(self) -> str: """Get name of species. Returns ------- str Name of species """ return self.get_name() def __repr__(self) -> str: """Get printable representation of RuntimeSpecies. Returns ------- str Printable representation of RuntimeSpecies """ return f"RuntimeSpecies({self._index}, '{self.get_name()}')"
[docs] class RuntimeReaction: """Wrapper for a reaction in the compiled network. Provides a similar API to makerates.Reaction but accesses the compiled Fortran data. """ def __init__(self, index: int, network_ref: ModuleType): """Initialize a runtime reaction wrapper. Parameters ---------- index : int 1-based reaction index in Fortran arrays network_ref : ModuleType Reference to the network module """ self._index = index self._network = network_ref self._array_idx = index - 1 # 0-based for array access
[docs] def get_reactants(self) -> list[int]: """Get the reactant species indices. Returns ------- list[int] List of species indices (1-based, 0 for NAN) """ return [ int(self._network.re1[self._array_idx]), int(self._network.re2[self._array_idx]), int(self._network.re3[self._array_idx]), ]
[docs] def get_products(self) -> list[int]: """Get the product species indices. Returns ------- list[int] List of species indices (1-based, 0 for NAN) """ return [ int(self._network.p1[self._array_idx]), int(self._network.p2[self._array_idx]), int(self._network.p3[self._array_idx]), int(self._network.p4[self._array_idx]), ]
[docs] def get_reactant_names(self) -> list[str]: """Get the names of reactant species. Returns ------- list[str] List of reactant names (NAN for empty slots) """ names = [] for idx in self.get_reactants(): if idx > 0: name = str(np.char.decode(self._network.specname[idx - 1])).strip() names.append(name) else: names.append("NAN") return names
[docs] def get_product_names(self) -> list[str]: """Get the names of product species. Returns ------- list[str] List of product names (NAN for empty slots) """ names = [] for idx in self.get_products(): if idx > 0: name = str(np.char.decode(self._network.specname[idx - 1])).strip() names.append(name) else: names.append("NAN") return names
[docs] def get_alpha(self) -> float: """Get the alpha parameter (pre-exponential factor). Returns ------- float Alpha parameter """ return float(self._network.alpha[self._array_idx])
[docs] def get_beta(self) -> float: """Get the beta parameter (temperature exponent). Returns ------- float Beta parameter """ return float(self._network.beta[self._array_idx])
[docs] def get_gamma(self) -> float: """Get the gamma parameter (activation energy). Returns ------- float Gamma parameter in Kelvin """ return float(self._network.gama[self._array_idx])
[docs] def get_templow(self) -> float: """Get the minimum valid temperature. Returns ------- float Minimum temperature in Kelvin """ return float(self._network.mintemps[self._array_idx])
[docs] def get_temphigh(self) -> float: """Get the maximum valid temperature. Returns ------- float Maximum temperature in Kelvin """ return float(self._network.maxtemps[self._array_idx])
[docs] def get_exothermicity(self) -> float: """Get the reaction exothermicity. Returns ------- float Exothermicity in erg """ return float(self._network.exothermicities[self._array_idx])
[docs] def get_reduced_mass(self) -> float | None: """Get the reduced mass for tunneling reactions. Returns ------- float | None Reduced mass in AMU (or None if not available) """ if self._array_idx < len(self._network.reducedmasses): return float(self._network.reducedmasses[self._array_idx]) return None
[docs] def get_rate(self) -> float | None: """Get the computed reaction rate from the last model run. Returns ------- float | None Computed rate (only meaningful after running a model) """ if self._array_idx < len(self._network.reactionrate): return float(self._network.reactionrate[self._array_idx]) return None
[docs] def set_alpha(self, value: float) -> None: """Set the alpha parameter. Parameters ---------- value : float New alpha value """ self._network.alpha[self._array_idx] = float(value)
[docs] def set_beta(self, value: float) -> None: """Set the beta parameter. Parameters ---------- value : float New beta value """ self._network.beta[self._array_idx] = float(value)
[docs] def set_gamma(self, value: float) -> None: """Set the gamma parameter. Parameters ---------- value : float New gamma value """ self._network.gama[self._array_idx] = float(value)
def __str__(self) -> str: """Get string representation of reaction. Returns ------- str Reaction with NAN removed. """ reactants = " + ".join([r for r in self.get_reactant_names() if r != "NAN"]) products = " + ".join([p for p in self.get_product_names() if p != "NAN"]) return f"{reactants} -> {products}" def __repr__(self) -> str: """Get printable string representation of reaction. Returns ------- str Printable string. """ return f"RuntimeReaction({self._index}, '{self}')"
[docs] class NetworkState: """Runtime interface to UCLCHEM's compiled chemical network. Loads the network from CSV files (on-disk version) and compares with the compiled Fortran network (in-memory version) to ensure consistency. **Thread Safety Warning:** This class modifies global Fortran module state and is **NOT thread-safe**. Do not use with multiprocessing, multithreading, or concurrent model runs. Examples -------- >>> from uclchem.advanced import NetworkState >>> network = NetworkState() >>> network.validate() # Check on-disk matches in-memory >>> print(f"Species: {len(network.species_list)}") Species: ... >>> print(f"Reactions: {len(network.reaction_list)}") Reactions: ... """ def __init__(self): """Initialize the NetworkState interface. Loads species and reactions from CSV files and compares with the compiled Fortran network data. Caches the initial state of all modifiable network parameters for fast resetting. """ self._network = network_module # Load CSV files from installed package self._load_csv_files() # Parse CSV data into Species and Reaction objects self._parse_species() self._parse_reactions() # Validate that on-disk matches in-memory self._validate_network() # Cache initial state of modifiable parameters for fast reset self._cache_initial_state() def _load_csv_files(self): """Load species and reaction CSV files from the installed package. Raises ------ FileNotFoundError If `"UCLCHEM_ROOT_DIR/species.csv"` or `"UCLCHEM_ROOT_DIR/reactions.csv"` are not valid files. """ species_path = UCLCHEM_ROOT_DIR / "species.csv" reactions_path = UCLCHEM_ROOT_DIR / "reactions.csv" if not species_path.is_file(): msg = f"Species CSV not found: {species_path}" raise FileNotFoundError(msg) if not reactions_path.is_file(): msg = f"Reactions CSV not found: {reactions_path}" raise FileNotFoundError(msg) self._species_df = get_species_table(species_path) self._reactions_df = get_reaction_table(reactions_path) def _parse_species(self): """Parse species DataFrame into Species objects.""" self.species_list = [] for _, row in self._species_df.iterrows(): # Create Species object from CSV row species = Species(row) self.species_list.append(species) def _parse_reactions(self): """Parse reactions DataFrame into Reaction objects.""" self.reaction_list = [] for _, row in self._reactions_df.iterrows(): # Create Reaction object from CSV row reaction_row = [row[field_name] for field_name in reaction_header] reaction = Reaction(reaction_row) self.reaction_list.append(reaction) def _validate_network(self) -> None: """Validate that on-disk network matches in-memory Fortran network. Raises ------ RuntimeError If the network validation failed. """ errors = [] # Check species count n_species_memory = len(self._network.specname) n_species_disk = len(self.species_list) if n_species_memory != n_species_disk: errors.append( f"Species count mismatch: {n_species_memory} in memory vs {n_species_disk} on disk" ) # Check reaction count n_reactions_memory = len(self._network.alpha) n_reactions_disk = len(self.reaction_list) if n_reactions_memory != n_reactions_disk: errors.append( f"Reaction count mismatch: {n_reactions_memory} in memory vs {n_reactions_disk} on disk" ) # Check species names match for i, species in enumerate(self.species_list): if i >= len(self._network.specname): break memory_name = str(np.char.decode(self._network.specname[i])).strip() disk_name = species.get_name() if memory_name != disk_name: errors.append( f"Species name mismatch at index {i}: '{memory_name}' in memory vs '{disk_name}' on disk" ) if len(errors) > 5: # noqa: PLR2004 # Limit error output errors.append("... (truncated)") break # Check reaction parameters match for i, reaction in enumerate(self.reaction_list): if i >= len(self._network.alpha): break memory_alpha = float(self._network.alpha[i]) disk_alpha = reaction.get_alpha() if not np.allclose(memory_alpha, disk_alpha): errors.append( f"Reaction {i} alpha mismatch: {memory_alpha} in memory vs {disk_alpha} on disk" ) if len(errors) > 10: # noqa: PLR2004 errors.append("... (truncated)") break if errors: warnings.warn( "Network validation failed:" + str(errors), RuntimeWarning, stacklevel=2 ) msg = "Network validation failed" raise RuntimeError(msg)
[docs] def validate(self) -> None: """Re-run validation to check on-disk matches in-memory. Useful after modifications to verify consistency. """ self._validate_network()
def _cache_initial_state(self) -> None: """Cache the initial state of all modifiable network parameters. This allows fast reset without re-reading CSV files. Caches: - Reaction parameters: alpha, beta, gama - Species parameters: bindingenergy, formationenthalpy """ # Cache reaction rate parameters (modifiable at runtime) self._initial_alpha = np.copy(self._network.alpha) self._initial_beta = np.copy(self._network.beta) self._initial_gama = np.copy(self._network.gama) # Cache species binding energies (modifiable at runtime) self._initial_bindingenergy = np.copy(self._network.bindingenergy) # Cache formation enthalpies if available (for heating/cooling) if hasattr(self._network, "formationenthalpy"): self._initial_formationenthalpy: np.ndarray | None = np.copy( self._network.formationenthalpy ) else: self._initial_formationenthalpy = None
[docs] def reset_state(self) -> None: """Reset the Fortran network to its initial cached state. Uses cached values instead of re-reading and parsing CSV files. Restores all modifiable network parameters to their initial values from when NetworkState was created. Modifiable arrays restored: - alpha, beta, gama: Reaction rate parameters - bindingenergy: Species binding energies - formationenthalpy: Formation enthalpies (if available) Examples -------- >>> network = NetworkState() >>> # Modify some reaction parameters >>> network._network.alpha[0] = 999.0 >>> # Restore to initial state >>> network.reset_state() """ # Restore reaction rate parameters from cache np.copyto(self._network.alpha, self._initial_alpha) np.copyto(self._network.beta, self._initial_beta) np.copyto(self._network.gama, self._initial_gama) # Restore species binding energies from cache np.copyto(self._network.bindingenergy, self._initial_bindingenergy)
# NOTE: formationenthalpy cannot be reset - causes bus error (read-only?) # Skipping formationenthalpy reset to avoid crash
[docs] def reset_network_from_csv(self) -> None: """Reset the Fortran network to match the cached initial state. This method now uses cached values for better performance and reliability. It's equivalent to reset_state() but kept for backward compatibility. Note: This uses the cached initial state from when NetworkState was created, not by re-reading CSV files. Use reset_state() for the same functionality with a clearer name. Modifiable arrays restored: - alpha, beta, gama: Reaction rate parameters - bindingenergy: Species binding energies Examples -------- >>> network = NetworkState() >>> # Modify some reaction... >>> network._network.alpha[0] = 999.0 >>> # Reset back to initial values >>> network.reset_state() """ # Use the cached reset method self.reset_state()