"""Runtime network interface for UCLCHEM's compiled chemical network.
This module provides RuntimeNetwork for runtime parameter modification during
model execution. Unlike the Network class in makerates (which supports full CRUD),
RuntimeNetwork provides read access and parameter modification only, as the
compiled Fortran network has fixed structure.
Key capabilities:
- Read all species and reactions
- Modify reaction parameters (alpha, beta, gamma)
- Modify species binding energies
- Disable reactions (set alpha=0)
- Reset to initial state
- Cannot add new species or reactions (Fortran arrays are fixed size)
- Cannot truly remove species or reactions (only disable)
Thread Safety Warning:
RuntimeNetwork modifies global Fortran module state and is NOT thread-safe.
Do not use with multiprocessing, multithreading, or concurrent model runs.
"""
import warnings
from types import ModuleType
import numpy as np
import pandas as pd
# Import the base network implementation from makerates
from uclchem.makerates.network import BaseNetwork
from uclchem.makerates.reaction import Reaction, skip_reaction_validation
from uclchem.makerates.species import Species
from uclchem.utils import UCLCHEM_ROOT_DIR, get_reaction_table, get_species_table
[docs]
class RuntimeNetwork(BaseNetwork):
"""Runtime interface to UCLCHEM's compiled Fortran network.
Provides read access and parameter modification for the compiled chemical
network during model execution. The network structure (species/reactions)
is fixed, but parameters can be modified.
To "remove" a reaction: set its alpha parameter to 0.0 using disable_reaction().
To reset changes: call :meth:`reset_to_initial_state`.
Examples
--------
>>> # Load runtime network
>>> network = RuntimeNetwork()
>>> print(f"Species: {len(network.get_species_list())}")
Species: ...
>>> print(f"Reactions: {len(network.get_reaction_list())}")
Reactions: ...
>>> # Modify parameters
>>> network.modify_reaction_parameters(0, alpha=1e-10, beta=2.0)
>>> network.change_binding_energy("#H2O", 5773.0)
>>> # Disable a reaction
>>> network.disable_reaction(5)
>>> # Reset when done
>>> network.reset_to_initial_state()
You can use this to do a sensitivity analysis in a simple way.
>>> # Artificially set a higher diffusion barrier of atomic hydrogen
>>> network.change_diffusion_barrier("#H", 600)
>>>
>>> # Run a model at the increased hydrogen diffusion barrier
>>> import uclchem
>>> param_dict = {"initialDens": 1e5, "initialTemp": 10, "finalTime": 1e5}
>>> high_diffusion_barrier_model = uclchem.model.Cloud(param_dict=param_dict)
>>>
>>> # Reset to initial state, and then run a "standard" model
>>> network.reset_to_initial_state()
>>> regular_diffusion_barrier_model = uclchem.model.Cloud(param_dict=param_dict)
>>>
>>> # Do some analysis to see the effect of a higher hydrogen diffusion barrier
>>> # ...
"""
# Fortran uses 9999 as a sentinel value for reaction type keywords
# (GAR, PHOTON, CRP, CRPHOT, etc.) instead of species indices
_FORTRAN_KEYWORD_SENTINEL = 9999
_ARRAYS_TO_CACHE = {"alpha", "beta", "gama", "bindingenergy", "diffusionbarrier"}
def __init__(self):
"""Initialize RuntimeNetwork by loading the compiled Fortran module.
Automatically imports uclchemwrap.network and validates against
species.csv and reactions.csv from the installation directory.
Raises
------
ImportError
If uclchemwrap.network cannot be imported
"""
# Import the compiled Fortran network module
try:
from uclchemwrap import ( # noqa: PLC0415
network as network_module, # optional compiled extension
)
except ImportError as err:
msg = (
"Cannot import Fortran network module. "
"Ensure UCLCHEM is properly compiled and installed."
)
raise ImportError(msg) from err
self._fortran = network_module
# Load CSV files for validation and better indexing
self._load_csv_files()
# Load species and reactions from Fortran
self._species_dict = self._load_species_from_fortran()
self._reactions_dict = self._load_reactions_from_fortran()
# Validate that CSV matches Fortran dimensions
self._validate_dimensions()
# Cache initial state for reset functionality
self._cache_initial_state()
def _load_csv_files(self):
"""Load species and reactions CSV files from installation directory.
These provide better indexing and validation against the Fortran network.
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_csv = get_species_table(species_path)
self._reactions_csv = get_reaction_table(reactions_path)
def _validate_dimensions(self):
"""Validate that CSV data matches Fortran network dimensions.
Raises
------
RuntimeError
If dimensions don't match
"""
n_species_csv = len(self._species_csv)
n_species_fortran = len(self._fortran.specname)
if n_species_csv != n_species_fortran:
msg = (
f"Species count mismatch: {n_species_csv} in CSV vs "
f"{n_species_fortran} in compiled Fortran network. "
"The installation may be corrupted or out of sync."
)
raise RuntimeError(msg)
n_reactions_csv = len(self._reactions_csv)
n_reactions_fortran = len(self._fortran.alpha)
if n_reactions_csv != n_reactions_fortran:
msg = (
f"Reaction count mismatch: {n_reactions_csv} in CSV vs "
f"{n_reactions_fortran} in compiled Fortran network. "
"The installation may be corrupted or out of sync."
)
raise RuntimeError(msg)
# Additional validation: check species names match
for i in range(min(10, n_species_csv)): # Check first 10 for quick validation
csv_name = self._species_csv.iloc[i]["NAME"]
fortran_name = str(np.char.decode(self._fortran.specname[i])).strip()
if csv_name != fortran_name:
warnings.warn(
f"Species name mismatch at index {i}: '{csv_name}' in CSV vs "
f"'{fortran_name}' in Fortran. Network may be out of sync.",
RuntimeWarning,
stacklevel=2,
)
break
def _load_species_from_fortran(self) -> dict[str, Species]:
"""Load species from Fortran arrays into Python Species objects.
Returns
-------
dict[str, Species]
Dictionary mapping species names to Species objects
"""
species_dict = {}
n_species = len(self._fortran.specname)
for i in range(n_species):
# Extract data from Fortran arrays
name = str(np.char.decode(self._fortran.specname[i])).strip()
mass = float(self._fortran.mass[i])
# Optional fields with bounds checking
if name.startswith("#") or name.startswith("@"):
ice_list_index = self._get_ice_list_index(name)
binding_energy = float(self._fortran.bindingenergy[ice_list_index])
diffusion_barrier = float(self._fortran.diffusionbarrier[ice_list_index])
else:
binding_energy = 0.0
diffusion_barrier = 0.0
# Formation enthalpy if available
enthalpy = (
float(self._fortran.formationenthalpy[i])
if hasattr(self._fortran, "formationenthalpy")
and i < len(self._fortran.formationenthalpy)
else 0.0
)
# Create Species object using CSV-style row format:
# NAME, MASS, BINDING_ENERGY, SOLID_FRACTION, MONO_FRACTION,
# VOLCANO_FRACTION, ENTHALPY, DIFFUSION_PREFACTOR, DIFFUSION_BARRIER
species_row: list[str | float] = [
name,
mass,
binding_energy,
1.0,
1.0,
0.0,
enthalpy,
0.0,
diffusion_barrier,
]
species = Species(species_row)
species_dict[name] = species
return species_dict
def _load_reactions_from_fortran(self) -> dict[int, Reaction]:
"""Load reactions from Fortran arrays into Python Reaction objects.
Fortran uses special sentinel values for reactant/product indices:
- Valid species: 1 to n_species (1-based indexing)
- Empty slot: 0
- Reaction keywords (GAR, PHOTON, CRP, etc.): 9999
Returns
-------
dict[int, Reaction]
Dictionary mapping reaction indices to Reaction objects
"""
reactions_dict = {}
n_reactions = len(self._fortran.alpha)
n_species = len(self._fortran.specname)
# Load reactions without validation - the compiled Fortran network
# may contain modeling simplifications (e.g., pseudo-hydrogenation)
# that intentionally don't conserve elements
with skip_reaction_validation():
for i in range(n_reactions):
# Extract reactant indices (1-based in Fortran, 0 for NAN)
re1 = int(self._fortran.re1[i])
re2 = int(self._fortran.re2[i])
re3 = int(self._fortran.re3[i]) if hasattr(self._fortran, "re3") else 0
# Extract product indices
p1 = int(self._fortran.p1[i])
p2 = int(self._fortran.p2[i])
p3 = int(self._fortran.p3[i])
p4 = int(self._fortran.p4[i])
# Convert indices to species names
# Valid species indices: 1 to n_species (1-based)
# Keyword sentinel (9999): fall back to CSV for GAR, PHOTON, etc.
reactant1 = self._get_reactant_name(i, re1, n_species, "REACTANT 1")
reactant2 = self._get_reactant_name(i, re2, n_species, "REACTANT 2")
reactant3 = self._get_reactant_name(i, re3, n_species, "REACTANT 3")
product1 = self._get_species_name(p1) if p1 > 0 else "NAN"
product2 = self._get_species_name(p2) if p2 > 0 else "NAN"
product3 = self._get_species_name(p3) if p3 > 0 else "NAN"
product4 = self._get_species_name(p4) if p4 > 0 else "NAN"
# Extract rate parameters
alpha = float(self._fortran.alpha[i])
beta = float(self._fortran.beta[i])
gamma = float(self._fortran.gama[i])
# Temperature ranges
temp_low = (
float(self._fortran.mintemps[i])
if hasattr(self._fortran, "mintemps")
else 0.0
)
temp_high = (
float(self._fortran.maxtemps[i])
if hasattr(self._fortran, "maxtemps")
else 1e6
)
# Optional fields
reduced_mass = (
float(self._fortran.reducedmasses[i])
if hasattr(self._fortran, "reducedmasses")
and i < len(self._fortran.reducedmasses)
else 0.0
)
exothermicity = (
float(self._fortran.exothermicities[i])
if hasattr(self._fortran, "exothermicities")
and i < len(self._fortran.exothermicities)
else 0.0
)
# Create Reaction object using CSV-style row format:
# R1, R2, R3, P1, P2, P3, P4, alpha, beta, gamma,
# Tmin, Tmax, reduced_mass, extrapolate, exothermicity
reaction_row: list[str | float] = [
reactant1,
reactant2,
reactant3,
product1,
product2,
product3,
product4,
alpha,
beta,
gamma,
temp_low,
temp_high,
reduced_mass,
0, # extrapolate flag
exothermicity,
]
reaction = Reaction(reaction_row)
reactions_dict[i] = reaction
return reactions_dict
def _get_species_name(self, index: int) -> str:
"""Get species name from 1-based Fortran index.
Parameters
----------
index : int
1-based species index in Fortran
Returns
-------
str
Species name
"""
if index <= 0 or index > len(self._fortran.specname):
return "NAN"
array_idx = index - 1 # Convert to 0-based
return str(np.char.decode(self._fortran.specname[array_idx])).strip()
def _get_reactant_name(
self, reaction_idx: int, fortran_idx: int, n_species: int, csv_column: str
) -> str:
"""Get reactant name from Fortran index, handling keyword sentinels.
Fortran uses different values for reactant indices:
- 1 to n_species: Valid species index (1-based)
- 0: Empty reactant slot (returns "NAN")
- 9999: Reaction type keyword (GAR, PHOTON, CRP, etc.)
For the keyword sentinel (9999), we fall back to the CSV file to
get the actual keyword string.
Parameters
----------
reaction_idx : int
Index of reaction in reactions list
fortran_idx : int
Fortran array value for this reactant
n_species : int
Total number of species in network
csv_column : str
Column name in reactions CSV ("REACTANT 1", etc.)
Returns
-------
str
Species name or reaction type keyword
"""
# Check if this is a valid species index (1-based)
if 0 < fortran_idx <= n_species:
return self._get_species_name(fortran_idx)
# For sentinel values (0 or 9999), fall back to CSV
# Use pd.isna() to properly detect NaN values from pandas
csv_val = self._reactions_csv.iloc[reaction_idx][csv_column]
if pd.isna(csv_val) or not isinstance(csv_val, str):
return "NAN"
# Return the keyword string (GAR, PHOTON, CRP, etc.)
return str(csv_val).strip().upper()
def _cache_initial_state(self):
"""Cache the initial state of all modifiable Fortran parameters.
Allows fast reset without re-reading CSV files.
Caches everything in :data:`RuntimeNetwork._ARRAYS_TO_CACHE`.
"""
self._initial_arrays = {}
for array_name in self._ARRAYS_TO_CACHE:
self._initial_arrays[array_name] = np.copy(getattr(self._fortran, array_name))
# ========================================================================
# Properties (NetworkABC Implementation)
# ========================================================================
# Note: Read operations (get_species_list, get_species_dict, get_specie,
# get_reaction_list, get_reaction_dict, get_reaction) are inherited from BaseNetwork
# ========================================================================
# Species Interface - Unsupported Operations
# ========================================================================
[docs]
def add_species(self, species: Species | list[Species]) -> None:
"""NOT SUPPORTED: Cannot add species to compiled Fortran network.
Parameters
----------
species : Species | list[Species]
Species instance or list of species to add.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot add species to RuntimeNetwork. "
"The compiled Fortran network has fixed structure. "
"Use Network class (from makerates) for building new networks."
)
raise NotImplementedError(msg)
[docs]
def remove_species(self, specie_name: str) -> None:
"""NOT SUPPORTED: Cannot remove species from compiled Fortran network.
Parameters
----------
specie_name : str
Name of the species.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot remove species from RuntimeNetwork. "
"The compiled Fortran network has fixed structure."
)
raise NotImplementedError(msg)
[docs]
def set_specie(self, species_name: str, species: Species) -> None:
"""NOT SUPPORTED: Cannot replace species in compiled Fortran network.
Parameters
----------
species_name : str
Name of the species.
species : Species
Species instance or list of species to add.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot set species in RuntimeNetwork. "
"Use change_binding_energy() to modify species parameters."
)
raise NotImplementedError(msg)
[docs]
def set_species_dict(self, new_species_dict: dict[str, Species]) -> None:
"""NOT SUPPORTED: Cannot replace species dictionary.
Parameters
----------
new_species_dict : dict[str, Species]
Replacement species dictionary.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = "Cannot replace species dictionary in RuntimeNetwork."
raise NotImplementedError(msg)
[docs]
def sort_species(self) -> None:
"""NOT SUPPORTED: Species order is fixed in compiled network.
Raises
------
NotImplementedError
Always - species order is fixed
"""
msg = "Cannot sort species in RuntimeNetwork. Species order is fixed."
raise NotImplementedError(msg)
# ========================================================================
# Reaction Interface - Unsupported Operations
# ========================================================================
[docs]
def add_reactions(self, reactions: Reaction | list[Reaction]) -> None:
"""NOT SUPPORTED: Cannot add reactions to compiled Fortran network.
Parameters
----------
reactions : Reaction | list[Reaction]
Reactions to add to the network.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot add reactions to RuntimeNetwork. "
"The compiled Fortran network has fixed structure. "
"Use Network class (from makerates) for building new networks."
)
raise NotImplementedError(msg)
[docs]
def remove_reaction(self, reaction: Reaction) -> None:
"""NOT SUPPORTED: Cannot remove reactions from compiled Fortran network.
Use disable_reaction() to set alpha=0 instead.
Parameters
----------
reaction : Reaction
Reaction instance to look up or modify.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot remove reactions from RuntimeNetwork. "
"Use disable_reaction() to set alpha=0 to effectively disable a reaction."
)
raise NotImplementedError(msg)
[docs]
def remove_reaction_by_index(self, reaction_idx: int) -> None:
"""NOT SUPPORTED: Cannot remove reactions from compiled Fortran network.
Use disable_reaction() to set alpha=0 instead.
Parameters
----------
reaction_idx : int
Index of the reaction in the network.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot remove reactions from RuntimeNetwork. "
"Use disable_reaction() to set alpha=0 to effectively disable a reaction."
)
raise NotImplementedError(msg)
[docs]
def set_reaction(self, reaction_idx: int, reaction: Reaction) -> None:
"""NOT SUPPORTED: Cannot replace reactions in compiled Fortran network.
Parameters
----------
reaction_idx : int
Index of the reaction in the network.
reaction : Reaction
Reaction instance to look up or modify.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = (
"Cannot replace reactions in RuntimeNetwork. "
"Use modify_reaction_parameters() to modify reaction parameters."
)
raise NotImplementedError(msg)
[docs]
def set_reaction_dict(self, new_dict: dict[int, Reaction]) -> None:
"""NOT SUPPORTED: Cannot replace reaction dictionary.
Parameters
----------
new_dict : dict[int, Reaction]
Replacement reactions dictionary.
Raises
------
NotImplementedError
Always - Fortran arrays have fixed size
"""
msg = "Cannot replace reaction dictionary in RuntimeNetwork."
raise NotImplementedError(msg)
[docs]
def sort_reactions(self) -> None:
"""NOT SUPPORTED: Reaction order is fixed in compiled network.
Raises
------
NotImplementedError
Always - reaction order is fixed
"""
msg = "Cannot sort reactions in RuntimeNetwork. Reaction order is fixed."
raise NotImplementedError(msg)
# Note: Query methods (get_reactions_by_types, find_similar_reactions,
# get_reaction_index) are inherited from BaseNetwork
# ========================================================================
# Parameter Modification Methods (NetworkABC Implementation)
# ========================================================================
def _get_species_index(self, specie: str) -> int:
# Find species index (1-based)
species_names = [
self._get_species_name(i + 1) for i in range(len(self._fortran.specname))
]
try:
species_idx = species_names.index(specie)
return species_idx
except ValueError as err:
msg = f"Species '{specie}' not found in network"
raise KeyError(msg) from err
def _get_ice_list_index(self, specie: str) -> int:
species_idx = self._get_species_index(specie)
for ice_list_index, species_index in enumerate(self._fortran.icelist):
if species_index == species_idx + 1:
return ice_list_index
msg = f"Species '{specie}' with index {species_idx} (base-0) not found in iceList"
raise KeyError(msg)
[docs]
def change_binding_energy(self, specie: str, new_binding_energy: float) -> None:
"""Change binding energy of a species (modifies Fortran array).
Parameters
----------
specie : str
Name of the species
new_binding_energy : float
New binding energy in Kelvin
"""
ice_list_idx = self._get_ice_list_index(specie)
# Modify Fortran array (0-based)
self._fortran.bindingenergy[ice_list_idx] = float(new_binding_energy)
# Update cached species object
if specie in self._species_dict:
self._species_dict[specie].set_binding_energy(new_binding_energy)
[docs]
def change_diffusion_barrier(self, specie: str, new_diffusion_barrier: float) -> None:
"""Change diffusion barrier of a species (modifies Fortran array).
Parameters
----------
specie : str
Name of the species.
new_diffusion_barrier : float
New diffusion barrier in Kelvin.
"""
ice_list_idx = self._get_ice_list_index(specie)
# Modify Fortran array (0-based)
self._fortran.diffusionbarrier[ice_list_idx] = float(new_diffusion_barrier)
# Update cached species object
if specie in self._species_dict:
self._species_dict[specie].set_diffusion_barrier(new_diffusion_barrier)
[docs]
def change_reaction_barrier(self, reaction: Reaction, barrier: float) -> None:
"""Change activation barrier of a reaction (modifies Fortran gamma).
Parameters
----------
reaction : Reaction
Reaction to modify
barrier : float
New activation barrier in Kelvin
Raises
------
RuntimeError
If reaction is not a reaction on the ices.
"""
if not reaction.is_ice_reaction():
msg = "Only ice reactions have modifiable barriers."
raise RuntimeError(msg)
reaction_idx = self.get_reaction_index(reaction)
self.modify_reaction_parameters(reaction_idx, gamma=barrier)
# ========================================================================
# RuntimeNetwork-Specific Methods
# ========================================================================
[docs]
def modify_reaction_parameters(
self,
reaction_idx: int,
alpha: float | None = None,
beta: float | None = None,
gamma: float | None = None,
) -> None:
"""Modify reaction rate parameters in Fortran arrays.
Parameters
----------
reaction_idx : int
Index of reaction to modify (0-based)
alpha : float | None
New alpha value (pre-exponential factor) (Default value = None)
beta : float | None
New beta value (temperature exponent) (Default value = None)
gamma : float | None
New gamma value (activation energy in K) (Default value = None)
Raises
------
IndexError
If reaction_idx out of range
Examples
--------
>>> network = RuntimeNetwork()
>>> network.modify_reaction_parameters(0, alpha=1e-10, beta=2.0)
>>> network.reset_to_initial_state()
"""
if reaction_idx < 0 or reaction_idx >= len(self._fortran.alpha):
msg = f"Reaction index {reaction_idx} out of range"
raise IndexError(msg)
if alpha is not None:
self._fortran.alpha[reaction_idx] = float(alpha)
if reaction_idx in self._reactions_dict:
self._reactions_dict[reaction_idx].set_alpha(float(alpha))
if beta is not None:
self._fortran.beta[reaction_idx] = float(beta)
if reaction_idx in self._reactions_dict:
self._reactions_dict[reaction_idx].set_beta(float(beta))
if gamma is not None:
self._fortran.gama[reaction_idx] = float(gamma)
if reaction_idx in self._reactions_dict:
self._reactions_dict[reaction_idx].set_gamma(float(gamma))
[docs]
def disable_reaction(self, reaction_idx: int) -> None:
"""Disable a reaction by setting alpha=0.
This is the only way to "remove" a reaction at runtime since
the Fortran network has fixed structure. Setting alpha=0 makes
the reaction have zero rate.
Parameters
----------
reaction_idx : int
Index of reaction to disable (0-based)
Examples
--------
>>> network = RuntimeNetwork()
>>> network.disable_reaction(5)
>>> network.reset_to_initial_state()
"""
self.modify_reaction_parameters(reaction_idx, alpha=0.0)
[docs]
def reset_to_initial_state(self) -> None:
"""Reset all Fortran parameters to their initial cached values.
Restores:
- All reaction parameters (alpha, beta, gamma)
- All species binding energies
Examples
--------
>>> network = RuntimeNetwork()
>>> network.modify_reaction_parameters(0, alpha=999.0)
>>> network.reset_to_initial_state() # Restores original alpha
"""
for array_name, array in self._initial_arrays.items():
np.copyto(getattr(self._fortran, array_name), array)
# Reload species and reactions to sync cached objects
self._species_dict = self._load_species_from_fortran()
self._reactions_dict = self._load_reactions_from_fortran()
@property
[docs]
def fortran_module(self) -> ModuleType:
"""Direct access to Fortran module for advanced users.
Warning: Use with caution. Direct modification bypasses safety checks.
Returns
-------
ModuleType
The underlying Fortran module handle for this runtime network.
"""
warnings.warn(
"Direct access to Fortran module is discouraged, this can break ungracefully. "
"Use GeneralSettings and instead",
stacklevel=2,
)
return self._fortran