Skip to content

Commit 9cc2cfd

Browse files
authored
Merge pull request #2646 for coverage-dependent adsorbate thermochemistry
Coverage-dependent thermochemistry for catalysis This PR adds in coverage dependent thermodynamic models for heterogeneous catalysis modeling. An adsorbate's enthalpy and entropy should be affected by other adsorbates around it. The PR enables RMG to use a polynomial model to estimate the change of an adsorbate's enthalpy or entropy based on the coverage of other adsorbates on the catalyst surface. By doing this, a Cantera yaml file with thermodynamic coverage dependent data can be generated at the end of a RMG simulation, and it can be used by Cantera (>=3.0) to run PFR simulation. RMG looks into the database to read the thermo coverage dependent models.
2 parents 8549aac + c9e20b1 commit 9cc2cfd

File tree

14 files changed

+775
-72
lines changed

14 files changed

+775
-72
lines changed

rmgpy/rmg/input.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ def database(
120120
def catalyst_properties(bindingEnergies=None,
121121
surfaceSiteDensity=None,
122122
metal=None,
123-
coverageDependence=False):
123+
coverageDependence=False,
124+
thermoCoverageDependence=False,):
124125
"""
125126
Specify the properties of the catalyst.
126127
Binding energies of C,H,O,N atoms, and the surface site density.
@@ -167,6 +168,7 @@ def catalyst_properties(bindingEnergies=None,
167168
else:
168169
logging.info("Coverage dependence is turned OFF")
169170
rmg.coverage_dependence = coverageDependence
171+
rmg.thermo_coverage_dependence = thermoCoverageDependence
170172

171173
def convert_binding_energies(binding_energies):
172174
"""
@@ -1088,7 +1090,8 @@ def surface_reactor(temperature,
10881090
sensitive_species=sensitive_species,
10891091
sensitivity_threshold=sensitivityThreshold,
10901092
sens_conditions=sens_conditions,
1091-
coverage_dependence=rmg.coverage_dependence)
1093+
coverage_dependence=rmg.coverage_dependence,
1094+
thermo_coverage_dependence=rmg.thermo_coverage_dependence)
10921095
rmg.reaction_systems.append(system)
10931096
system.log_initial_conditions(number=len(rmg.reaction_systems))
10941097

rmgpy/rmg/main.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import logging
3838
import marshal
3939
import os
40+
import re
4041
import resource
4142
import shutil
4243
import sys
@@ -193,6 +194,7 @@ def clear(self):
193194
self.surface_site_density = None
194195
self.binding_energies = None
195196
self.coverage_dependence = False
197+
self.thermo_coverage_dependence = False
196198
self.forbidden_structures = []
197199

198200
self.reaction_model = None
@@ -510,7 +512,7 @@ def initialize(self, **kwargs):
510512

511513
# Read input file
512514
self.load_input(self.input_file)
513-
515+
514516
# Check if ReactionMechanismSimulator reactors are being used
515517
# if RMS is not installed but the user attempted to use it, the load_input_file would have failed
516518
# if RMS is not installed and they did not use it, we avoid calling certain functions that would raise an error
@@ -523,6 +525,7 @@ def initialize(self, **kwargs):
523525
self.reaction_model.core.phase_system.phases["Surface"].site_density = self.surface_site_density.value_si
524526
self.reaction_model.edge.phase_system.phases["Surface"].site_density = self.surface_site_density.value_si
525527
self.reaction_model.coverage_dependence = self.coverage_dependence
528+
self.reaction_model.thermo_coverage_dependence = self.thermo_coverage_dependence
526529

527530
if kwargs.get("restart", ""):
528531
import rmgpy.rmg.input
@@ -768,7 +771,7 @@ def register_listeners(self, requires_rms=False):
768771
"""
769772

770773
self.attach(ChemkinWriter(self.output_directory))
771-
774+
772775
self.attach(RMSWriter(self.output_directory))
773776

774777
if self.generate_output_html:
@@ -1221,6 +1224,7 @@ def execute(self, initialize=True, **kwargs):
12211224
# generate Cantera files chem.yaml & chem_annotated.yaml in a designated `cantera` output folder
12221225
try:
12231226
if any([s.contains_surface_site() for s in self.reaction_model.core.species]):
1227+
# Surface (catalytic) chemistry
12241228
self.generate_cantera_files(
12251229
os.path.join(self.output_directory, "chemkin", "chem-gas.inp"),
12261230
surface_file=(os.path.join(self.output_directory, "chemkin", "chem-surface.inp")),
@@ -1229,6 +1233,34 @@ def execute(self, initialize=True, **kwargs):
12291233
os.path.join(self.output_directory, "chemkin", "chem_annotated-gas.inp"),
12301234
surface_file=(os.path.join(self.output_directory, "chemkin", "chem_annotated-surface.inp")),
12311235
)
1236+
1237+
if self.thermo_coverage_dependence:
1238+
# Build coverage_deps: {species_name: string_to_add_to_yaml}
1239+
coverage_deps = {}
1240+
for s in self.reaction_model.core.species:
1241+
if s.contains_surface_site() and s.thermo.thermo_coverage_dependence:
1242+
s_name = s.to_chemkin()
1243+
for dep_sp_adj, parameters in s.thermo.thermo_coverage_dependence.items():
1244+
mol = Molecule().from_adjacency_list(dep_sp_adj)
1245+
for sp in self.reaction_model.core.species:
1246+
if sp.is_isomorphic(mol, strict=False):
1247+
if s_name not in coverage_deps:
1248+
coverage_deps[s_name] = ' coverage-dependencies:'
1249+
coverage_deps[s_name] += f"""
1250+
{sp.to_chemkin()}:
1251+
model: {parameters['model']}
1252+
enthalpy-coefficients: {[v.value_si for v in parameters['enthalpy-coefficients']]}
1253+
entropy-coefficients: {[v.value_si for v in parameters['entropy-coefficients']]}
1254+
units: {{energy: J, quantity: mol}}
1255+
"""
1256+
break
1257+
1258+
for yaml_path in [
1259+
os.path.join(self.output_directory, "cantera", "chem.yaml"),
1260+
os.path.join(self.output_directory, "cantera", "chem_annotated.yaml"),
1261+
]:
1262+
_add_coverage_dependence_to_cantera_yaml(yaml_path, coverage_deps)
1263+
12321264
else: # gas phase only
12331265
self.generate_cantera_files(os.path.join(self.output_directory, "chemkin", "chem.inp"))
12341266
self.generate_cantera_files(os.path.join(self.output_directory, "chemkin", "chem_annotated.inp"))
@@ -2392,6 +2424,45 @@ def obj(y):
23922424
self.scaled_condition_list.append(scaled_new_cond)
23932425
return
23942426

2427+
def _add_coverage_dependence_to_cantera_yaml(yaml_path, coverage_deps):
2428+
"""Modify a Cantera YAML file in-place to add coverage-dependent surface thermo.
2429+
2430+
Makes targeted text insertions rather than loading and re-dumping the whole
2431+
file, so original formatting is preserved everywhere except the new lines.
2432+
2433+
Args:
2434+
yaml_path: path to the Cantera YAML file to modify
2435+
coverage_deps: dict mapping species ChemKin names to their coverage-dependency string.
2436+
"""
2437+
with open(yaml_path, 'r') as f:
2438+
content = f.read()
2439+
2440+
# --- Modify the surface phase ---
2441+
# Replace 'ideal-surface' with 'coverage-dependent-surface' and add reference-state-coverage.
2442+
content = content.replace(
2443+
' thermo: ideal-surface\n',
2444+
' thermo: coverage-dependent-surface\n reference-state-coverage: 0.11\n',
2445+
1,
2446+
)
2447+
2448+
# --- Insert coverage-dependencies block after each relevant species entry ---
2449+
for species_name, deps in coverage_deps.items():
2450+
match = re.search(r'^- name: ' + re.escape(species_name) + r'\n', content, re.MULTILINE)
2451+
if not match:
2452+
logging.warning(
2453+
f"Species {species_name} not found in {yaml_path}; skipping coverage-dependency insertion."
2454+
)
2455+
continue
2456+
2457+
after = match.end()
2458+
end_match = re.search(r'\n(?=(?:- |\n|\w))', content[after:])
2459+
if end_match:
2460+
insert_pos = after + end_match.start() + 1
2461+
content = content[:insert_pos] + deps + content[insert_pos:]
2462+
else:
2463+
content = content.rstrip('\n') + '\n' + deps
2464+
with open(yaml_path, 'w') as f:
2465+
f.write(content)
23952466

23962467
def log_conditions(rmg_memories, index):
23972468
"""

rmgpy/solver/surface.pyx

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ cimport rmgpy.constants as constants
4343
from rmgpy.quantity import Quantity
4444
from rmgpy.quantity cimport ScalarQuantity
4545
from rmgpy.solver.base cimport ReactionSystem
46+
import copy
47+
from rmgpy.molecule import Molecule
4648

4749
cdef class SurfaceReactor(ReactionSystem):
4850
"""
@@ -66,9 +68,13 @@ cdef class SurfaceReactor(ReactionSystem):
6668
cdef public ScalarQuantity surface_site_density
6769
cdef public np.ndarray reactions_on_surface # (catalyst surface, not core/edge surface)
6870
cdef public np.ndarray species_on_surface # (catalyst surface, not core/edge surface)
71+
cdef public np.ndarray thermo_coeff_matrix
72+
cdef public np.ndarray stoi_matrix
6973

7074
cdef public bint coverage_dependence
7175
cdef public dict coverage_dependencies
76+
cdef public bint thermo_coverage_dependence
77+
7278

7379

7480
def __init__(self,
@@ -84,6 +90,7 @@ cdef class SurfaceReactor(ReactionSystem):
8490
sensitivity_threshold=1e-3,
8591
sens_conditions=None,
8692
coverage_dependence=False,
93+
thermo_coverage_dependence=False,
8794
):
8895
ReactionSystem.__init__(self,
8996
termination,
@@ -103,6 +110,7 @@ cdef class SurfaceReactor(ReactionSystem):
103110
self.surface_volume_ratio = Quantity(surface_volume_ratio)
104111
self.surface_site_density = Quantity(surface_site_density)
105112
self.coverage_dependence = coverage_dependence
113+
self.thermo_coverage_dependence = thermo_coverage_dependence
106114
self.V = 0 # will be set from ideal gas law in initialize_model
107115
self.constant_volume = True
108116
self.sens_conditions = sens_conditions
@@ -166,6 +174,10 @@ cdef class SurfaceReactor(ReactionSystem):
166174
)
167175
cdef np.ndarray[np.int_t, ndim=1] species_on_surface, reactions_on_surface
168176
cdef Py_ssize_t index
177+
cdef np.ndarray thermo_coeff_matrix = np.zeros((len(self.species_index), len(self.species_index), 6), dtype=np.float64)
178+
cdef np.ndarray stoi_matrix = np.zeros((self.reactant_indices.shape[0], len(self.species_index)), dtype=np.float64)
179+
if self.thermo_coverage_dependence:
180+
self.thermo_coeff_matrix = thermo_coeff_matrix
169181
#: 1 if it's on a surface, 0 if it's in the gas phase
170182
reactions_on_surface = np.zeros((self.num_core_reactions + self.num_edge_reactions), int)
171183
species_on_surface = np.zeros((self.num_core_species), int)
@@ -195,6 +207,49 @@ cdef class SurfaceReactor(ReactionSystem):
195207
means that Species with index 2 in the current simulation is used in
196208
Reaction 3 with parameters a=0.1, m=-1, E=12 kJ/mol
197209
"""
210+
for sp, sp_index in self.species_index.items():
211+
if sp.contains_surface_site():
212+
if self.thermo_coverage_dependence and sp.thermo.thermo_coverage_dependence:
213+
for spec, parameters in sp.thermo.thermo_coverage_dependence.items():
214+
molecule = Molecule().from_adjacency_list(spec)
215+
for species in self.species_index.keys():
216+
if species.is_isomorphic(molecule, strict=False):
217+
species_index = self.species_index[species]
218+
enthalpy_coeff = np.array([p.value_si for p in parameters['enthalpy-coefficients']])
219+
entropy_coeff = np.array([p.value_si for p in parameters['entropy-coefficients']])
220+
thermo_polynomials = np.concatenate((enthalpy_coeff, entropy_coeff), axis=0)
221+
self.thermo_coeff_matrix[sp_index, species_index] = [x for x in thermo_polynomials]
222+
# create a stoichiometry matrix for reaction enthalpy and entropy correction
223+
# due to thermodynamic coverage dependence
224+
if self.thermo_coverage_dependence:
225+
ir = self.reactant_indices
226+
ip = self.product_indices
227+
for rxn_id, rxn_stoi_num in enumerate(stoi_matrix):
228+
if ir[rxn_id, 0] >= self.num_core_species or ir[rxn_id, 1] >= self.num_core_species or ir[rxn_id, 2] >= self.num_core_species:
229+
continue
230+
elif ip[rxn_id, 0] >= self.num_core_species or ip[rxn_id, 1] >= self.num_core_species or ip[rxn_id, 2] >= self.num_core_species:
231+
continue
232+
else:
233+
if ir[rxn_id, 1] == -1: # only one reactant
234+
rxn_stoi_num[ir[rxn_id, 0]] += -1
235+
elif ir[rxn_id, 2] == -1: # only two reactants
236+
rxn_stoi_num[ir[rxn_id, 0]] += -1
237+
rxn_stoi_num[ir[rxn_id, 1]] += -1
238+
else: # three reactants
239+
rxn_stoi_num[ir[rxn_id, 0]] += -1
240+
rxn_stoi_num[ir[rxn_id, 1]] += -1
241+
rxn_stoi_num[ir[rxn_id, 2]] += -1
242+
if ip[rxn_id, 1] == -1: # only one product
243+
rxn_stoi_num[ip[rxn_id, 0]] += 1
244+
elif ip[rxn_id, 2] == -1: # only two products
245+
rxn_stoi_num[ip[rxn_id, 0]] += 1
246+
rxn_stoi_num[ip[rxn_id, 1]] += 1
247+
else: # three products
248+
rxn_stoi_num[ip[rxn_id, 0]] += 1
249+
rxn_stoi_num[ip[rxn_id, 1]] += 1
250+
rxn_stoi_num[ip[rxn_id, 2]] += 1
251+
self.stoi_matrix = stoi_matrix
252+
198253
self.species_on_surface = species_on_surface
199254
self.reactions_on_surface = reactions_on_surface
200255

@@ -378,9 +433,10 @@ cdef class SurfaceReactor(ReactionSystem):
378433
cdef np.ndarray[np.float64_t, ndim=2] jacobian, dgdk
379434
cdef list list_of_coverage_deps
380435
cdef double surface_site_fraction, total_sites, a, m, E
381-
382-
383-
436+
cdef np.ndarray[np.float64_t, ndim=1] coverages, coverages_squared, temperature_scaled_coverages
437+
cdef np.ndarray[np.float64_t, ndim=2] thermo_dep_coverage
438+
cdef np.ndarray[np.float64_t, ndim=1] free_energy_coverage_corrections, rxns_free_energy_change, corrected_K_eq
439+
cdef double sp_free_energy_correction
384440
ir = self.reactant_indices
385441
ip = self.product_indices
386442
equilibrium_constants = self.Keq
@@ -414,14 +470,33 @@ cdef class SurfaceReactor(ReactionSystem):
414470
V = self.V # constant volume reactor
415471
A = self.V * surface_volume_ratio_si # area
416472
total_sites = self.surface_site_density.value_si * A # todo: double check units
417-
418473
for j in range(num_core_species):
419474
if species_on_surface[j]:
420475
C[j] = (N[j] / V) / surface_volume_ratio_si
421476
else:
422477
C[j] = N[j] / V
423478
#: surface species are in mol/m2, gas phase are in mol/m3
424479
core_species_concentrations[j] = C[j]
480+
481+
# Thermodynamic coverage dependence
482+
if self.thermo_coverage_dependence:
483+
coverages = np.where(species_on_surface, N / total_sites, 0.0)
484+
coverages_squared = coverages * coverages
485+
temperature_scaled_coverages = -self.T.value_si * coverages
486+
thermo_dep_coverage = np.empty((6, coverages.shape[0]), dtype=np.float64)
487+
thermo_dep_coverage[0, :] = coverages
488+
thermo_dep_coverage[1, :] = coverages_squared
489+
thermo_dep_coverage[2, :] = coverages_squared * coverages
490+
thermo_dep_coverage[3, :] = temperature_scaled_coverages
491+
thermo_dep_coverage[4, :] = temperature_scaled_coverages * coverages
492+
thermo_dep_coverage[5, :] = temperature_scaled_coverages * coverages_squared
493+
free_energy_coverage_corrections = np.empty(len(self.thermo_coeff_matrix), dtype=np.float64)
494+
for i, matrix in enumerate(self.thermo_coeff_matrix):
495+
free_energy_coverage_corrections[i] = np.diag(np.dot(matrix, thermo_dep_coverage)).sum()
496+
rxns_free_energy_change = np.matmul(self.stoi_matrix,free_energy_coverage_corrections)
497+
corrected_K_eq = copy.deepcopy(self.Keq)
498+
corrected_K_eq *= np.exp(-1 * rxns_free_energy_change / (constants.R * self.T.value_si))
499+
kr = kf / corrected_K_eq
425500

426501
# Coverage dependence
427502
coverage_corrections = np.ones_like(kf, float)

rmgpy/thermo/nasa.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ cdef class NASAPolynomial(HeatCapacityModel):
5656
cdef class NASA(HeatCapacityModel):
5757

5858
cdef public NASAPolynomial poly1, poly2, poly3
59+
cdef public dict _thermo_coverage_dependence
5960

6061
cpdef NASAPolynomial select_polynomial(self, double T)
6162

0 commit comments

Comments
 (0)