Skip to content
Open
4 changes: 4 additions & 0 deletions rmgpy/kinetics/arrhenius.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ cdef class Arrhenius(KineticsModel):
import scipy.stats
if not all(np.isfinite(klist)):
raise ValueError("Rates must all be finite, not inf or NaN")
if any(klist==0):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather we filtered the bad samples where klist is built than raise here. The five reverse_*_rate helpers (reaction.py:1127, 1150, 1174, 1201, 1227) each build klist[i].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raise ValueError("Rates must all be nonzero; a zero rate coefficient cannot be fit in log space.")
if any(klist<0):
if not all(klist<0):
raise ValueError("Rates must all be positive or all be negative.")
Expand Down Expand Up @@ -1377,6 +1379,8 @@ cdef class ArrheniusChargeTransfer(KineticsModel):
import scipy.stats
if not all(np.isfinite(klist)):
raise ValueError("Rates must all be finite, not inf or NaN")
if any(klist==0):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same hole is left open in two places: SurfaceChargeTransfer.fit_to_data and StickingCoefficient.fit_to_data. Can you add this raise there as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, StickingCoefficient.fit_to_data has no isfinite or sign validation at all unlike the other three, so inf/NaN rates reach the log fit too. i can make it a separate PR? or fold it into this?

raise ValueError("Rates must all be nonzero; a zero rate coefficient cannot be fit in log space.")
if any(klist<0):
if not all(klist<0):
raise ValueError("Rates must all be positive or all be negative.")
Expand Down
5 changes: 5 additions & 0 deletions rmgpy/kinetics/surface.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ cdef class StickingCoefficient(KineticsModel):
"""
import scipy.stats

if any(klist==0):
raise ValueError("Rates must all be nonzero; a zero rate coefficient cannot be fit in log space.")

assert len(Tlist) == len(klist), "length of temperatures and rates must be the same"
if len(Tlist) < 3 + three_params:
raise KineticsError('Not enough degrees of freedom to fit this Arrhenius expression')
Expand Down Expand Up @@ -973,6 +976,8 @@ cdef class SurfaceChargeTransfer(KineticsModel):
import scipy.stats
if not all(np.isfinite(klist)):
raise ValueError("Rates must all be finite, not inf or NaN")
if any(klist==0):
raise ValueError("Rates must all be nonzero; a zero rate coefficient cannot be fit in log space.")
if any(klist<0):
if not all(klist<0):
raise ValueError("Rates must all be positive or all be negative.")
Expand Down
2 changes: 1 addition & 1 deletion rmgpy/reaction.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ cdef class Reaction:

cpdef ensure_species(self, bint reactant_resonance=?, bint product_resonance=?, bint save_order=?)

cpdef list check_collision_limit_violation(self, float t_min, float t_max, float p_min, float p_max)
cpdef tuple check_collision_limit_violation(self, float t_min, float t_max, float p_min, float p_max)

cpdef calculate_coll_limit(self, float temp, bint reverse=?)

Expand Down
107 changes: 77 additions & 30 deletions rmgpy/reaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,8 +1142,9 @@ def reverse_arrhenius_rate(self, k_forward, reverse_units, Tmin=None, Tmax=None)
klist = np.zeros_like(Tlist)
for i in range(len(Tlist)):
klist[i] = kf.get_rate_coefficient(Tlist[i]) / self.get_equilibrium_constant(Tlist[i])
Tfit, kfit = _drop_zero_rate_samples(Tlist, klist)
kr = Arrhenius()
kr.fit_to_data(Tlist, klist, reverse_units, kf.T0.value_si)
kr.fit_to_data(Tfit, kfit, reverse_units, kf.T0.value_si)
kr.solute = kf.solute
return kr

Expand All @@ -1166,8 +1167,9 @@ def reverse_surface_arrhenius_rate(self, k_forward, reverse_units, Tmin=None, Tm
klist = np.zeros_like(Tlist)
for i in range(len(Tlist)):
klist[i] = kf.get_rate_coefficient(Tlist[i]) / self.get_equilibrium_constant(Tlist[i])
Tfit, kfit = _drop_zero_rate_samples(Tlist, klist)
kr = SurfaceArrhenius()
kr.fit_to_data(Tlist, klist, reverse_units, kf.T0.value_si)
kr.fit_to_data(Tfit, kfit, reverse_units, kf.T0.value_si)
kr.solute = kf.solute
return kr

Expand All @@ -1193,8 +1195,9 @@ def reverse_sticking_coeff_rate(self, k_forward, reverse_units, surface_site_den
klist[i] = \
self.get_surface_rate_coefficient(Tlist[i], surface_site_density=surface_site_density) / \
self.get_equilibrium_constant(Tlist[i], surface_site_density=surface_site_density)
Tfit, kfit = _drop_zero_rate_samples(Tlist, klist)
kr = SurfaceArrhenius()
kr.fit_to_data(Tlist, klist, reverse_units, kf.T0.value_si)
kr.fit_to_data(Tfit, kfit, reverse_units, kf.T0.value_si)
kr.solute = kf.solute
return kr

Expand All @@ -1219,8 +1222,9 @@ def reverse_surface_charge_transfer_rate(self, k_forward, reverse_units, Tmin=No
klist = np.zeros_like(Tlist)
for i in range(len(Tlist)):
klist[i] = kf.get_rate_coefficient(Tlist[i],V0) / self.get_equilibrium_constant(Tlist[i],V0)
Tfit, kfit = _drop_zero_rate_samples(Tlist, klist)
kr = SurfaceChargeTransfer(alpha=kf.alpha.value, electrons=-1*self.electrons, V0=(V0,'V'))
kr.fit_to_data(Tlist, klist, reverse_units, kf.T0.value_si)
kr.fit_to_data(Tfit, kfit, reverse_units, kf.T0.value_si)
kr.solute = kf.solute
return kr

Expand All @@ -1243,8 +1247,9 @@ def reverse_arrhenius_charge_transfer_rate(self, k_forward, reverse_units, Tmin=
klist = np.zeros_like(Tlist)
for i in range(len(Tlist)):
klist[i] = kf.get_rate_coefficient(Tlist[i],V0) / self.get_equilibrium_constant(Tlist[i],V0)
Tfit, kfit = _drop_zero_rate_samples(Tlist, klist)
kr = ArrheniusChargeTransfer(alpha=kf.alpha.value, electrons=-1*self.electrons, V0=(V0,'V'))
kr.fit_to_data(Tlist, klist, reverse_units, kf.T0.value_si)
kr.fit_to_data(Tfit, kfit, reverse_units, kf.T0.value_si)
kr.solute = kf.solute
return kr

Expand Down Expand Up @@ -1737,7 +1742,9 @@ def check_collision_limit_violation(self, t_min, t_max, p_min, p_max):
"""
Warn if a core reaction violates the collision limit rate in either the forward or reverse direction
at the relevant extreme T/P conditions. Assuming a monotonic behaviour of the kinetics.
Returns a list with the reaction object and the direction in which the violation was detected.
Returns ``(violator_list, skipped)``, where `violator_list` holds the reaction object and the
direction in which each violation was detected, and `skipped` counts the direction/condition
pairs that could not be evaluated.
"""
conditions = [[t_min, p_min]]
if t_min != t_max:
Expand All @@ -1748,38 +1755,63 @@ def check_collision_limit_violation(self, t_min, t_max, p_min, p_max):
conditions.append([t_max, p_max])
logging.debug("Checking whether reaction {0} violates the collision rate limit...".format(self))
violator_list = []
kf_list = []
kr_list = []
collision_limit_f = []
collision_limit_r = []
forward_checks = []
reverse_checks = []
skipped = 0
reverse_kinetics = None
if len(self.products) >= 2:
try:
reverse_kinetics = self.generate_reverse_rate_coefficient()
except (ReactionError, KineticsError, ZeroDivisionError, OverflowError) as err:
logging.warning(
"Skipping reverse collision limit check for reaction %s because the reverse rate "
"coefficient could not be generated: %s",
self, err,
)
skipped += len(conditions)
for condition in conditions:
temp, pressure = condition
if len(self.reactants) >= 2:
try:
collision_limit_f.append(self.calculate_coll_limit(temp=condition[0], reverse=False))
limit_f = self.calculate_coll_limit(temp=temp, reverse=False)
except ValueError:
continue
skipped += 1
else:
kf_list.append(self.get_rate_coefficient(condition[0], condition[1]))
if len(self.products) >= 2:
try:
kf = self.get_rate_coefficient(temp, pressure)
except (ReactionError, KineticsError, ZeroDivisionError, OverflowError) as err:
logging.warning(
"Skipping forward collision limit check for reaction %s at %.1f K, %.3g Pa "
"because rate evaluation failed: %s",
self, temp, pressure, err,
)
skipped += 1
else:
forward_checks.append((kf, limit_f, condition))
if len(self.products) >= 2 and reverse_kinetics is not None:
try:
collision_limit_r.append(self.calculate_coll_limit(temp=condition[0], reverse=True))
limit_r = self.calculate_coll_limit(temp=temp, reverse=True)
except ValueError:
continue
skipped += 1
else:
kr_list.append(self.generate_reverse_rate_coefficient().get_rate_coefficient(condition[0], condition[1]))
if len(self.reactants) >= 2:
for i, k in enumerate(kf_list):
if k > collision_limit_f[i]:
ratio = k / collision_limit_f[i]
condition = '{0} K, {1:.1f} bar'.format(conditions[i][0], conditions[i][1] / 1e5)
violator_list.append([self, 'forward', ratio, condition])
if len(self.products) >= 2:
for i, k in enumerate(kr_list):
if k > collision_limit_r[i]:
ratio = k / collision_limit_r[i]
condition = '{0} K, {1:.1f} bar'.format(conditions[i][0], conditions[i][1] / 1e5)
violator_list.append([self, 'reverse', ratio, condition])
return violator_list
try:
kr = reverse_kinetics.get_rate_coefficient(temp, pressure)
except (ReactionError, KineticsError, ZeroDivisionError, OverflowError) as err:
logging.warning(
"Skipping reverse collision limit check for reaction %s at %.1f K, %.3g Pa "
"because reverse rate evaluation failed: %s",
self, temp, pressure, err,
)
skipped += 1
else:
reverse_checks.append((kr, limit_r, condition))
for direction, checks in (('forward', forward_checks), ('reverse', reverse_checks)):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If every condition gets skipped, check_model still prints "No collision rate violators found in the model's core." (main.py:1695), which isn't true. Could you return a skipped count alongside violator_list so the summary can say how much was actually checked?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true. it now returns (violator_list, skipped) and check_model tracks skipped check and skipped reactions separately, reporting when either is non-zero. but know this changed cpdef return type from list to tuple

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since its a public API on the defintion change, people will need to re-compile again but i guess that is expected for every RMG update.

for k, collision_limit, (temp, pressure) in checks:
if k > collision_limit:
ratio = k / collision_limit
condition = '{0} K, {1:.1f} bar'.format(temp, pressure / 1e5)
violator_list.append([self, direction, ratio, condition])
return violator_list, skipped

def calculate_coll_limit(self, temp, reverse=False):
"""
Expand Down Expand Up @@ -1841,6 +1873,21 @@ def generate_high_p_limit_kinetics(self):
"""
raise NotImplementedError("generate_high_p_limit_kinetics is not implemented for all Reaction subclasses.")

def _drop_zero_rate_samples(Tlist, klist):
"""
Return the subset of `Tlist` and `klist` for which the rate coefficient is nonzero.

Reverse rate coefficients underflow to exactly zero at the cold end of the fitting range for
strongly endothermic reactions. Such a sample carries no information for a fit performed in
log space, and log(0) would make every fitted parameter NaN, so it is dropped instead.
"""
nonzero = klist != 0
if not nonzero.all():
logging.debug("Dropping %d of %d rate coefficient samples that underflowed to zero "
"before fitting.", (~nonzero).sum(), len(klist))
return Tlist[nonzero], klist[nonzero]


def _same_object(object1, object2, _check_identical=False, _only_check_label=False,
_generate_initial_map=False, _strict=True, _save_order=False):
if _only_check_label:
Expand Down
22 changes: 21 additions & 1 deletion rmgpy/rmg/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1643,11 +1643,24 @@ def check_model(self):

# Check all core reactions (in both directions) for collision limit violation
violators = []
skipped_checks = 0
skipped_reactions = 0
for rxn in self.reaction_model.core.reactions:
if rxn.is_surface_reaction():
# Don't check collision limits for surface reactions.
continue
violator_list = rxn.check_collision_limit_violation(t_min=self.Tmin, t_max=self.Tmax, p_min=self.Pmin, p_max=self.Pmax)
try:
violator_list, skipped = rxn.check_collision_limit_violation(
t_min=self.Tmin, t_max=self.Tmax, p_min=self.Pmin, p_max=self.Pmax
)
except Exception:
logging.warning(
"Skipping collision limit check for reaction %s because evaluation failed.",
rxn, exc_info=True,
)
skipped_reactions += 1
continue
skipped_checks += skipped
if violator_list:
violators.extend(violator_list)
# Whether or not violators were found, rename 'collision_rate_violators.log' if it exists
Expand Down Expand Up @@ -1682,6 +1695,13 @@ def check_model(self):
violators_f.write(
f"{rxn_string}\n" f"Direction: {direction}\n" f"Violation factor: {ratio:.2f}\n" f"Violation condition: {condition}\n\n\n"
)
elif skipped_checks or skipped_reactions:
logging.info(
"No collision rate violators found among the checks that could be evaluated. "
"%d individual checks and %d whole reactions were skipped because their rate "
"coefficients could not be evaluated; see the warnings above.",
skipped_checks, skipped_reactions,
)
else:
logging.info("No collision rate violators found in the model's core.")

Expand Down
24 changes: 16 additions & 8 deletions rmgpy/solver/base.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -1323,7 +1323,7 @@ cdef class ReactionSystem(DASx):
k_j is the rate parameter for the jth core reaction.
"""
cdef np.ndarray[np.int_t, ndim=2] ir, ip
cdef np.ndarray[np.float64_t, ndim=1] kf, kr, C, deriv
cdef np.ndarray[np.float64_t, ndim=1] kf, Keq, C, deriv
cdef np.ndarray[np.float64_t, ndim=2] rate_deriv
cdef double fderiv, rderiv, flux, V
cdef int j, num_core_reactions, num_core_species
Expand All @@ -1334,7 +1334,7 @@ cdef class ReactionSystem(DASx):
ip = self.product_indices

kf = self.kf
kr = self.kb
Keq = self.Keq

num_core_reactions = len(self.core_reaction_rates)
num_core_species = len(self.core_species_concentrations)
Expand All @@ -1355,12 +1355,20 @@ cdef class ReactionSystem(DASx):
else: # three reactants
fderiv = C[ir[j, 0]] * C[ir[j, 1]] * C[ir[j, 2]]

if ip[j, 1] == -1: # only one reactant
rderiv = kr[j] / kf[j] * C[ip[j, 0]]
elif ip[j, 2] == -1: # only two reactants
rderiv = kr[j] / kf[j] * C[ip[j, 0]] * C[ip[j, 1]]
else: # three reactants
rderiv = kr[j] / kf[j] * C[ip[j, 0]] * C[ip[j, 1]] * C[ip[j, 2]]
# kb is always built as kf/Keq, so kb/kf is identically 1/Keq. Dividing by Keq avoids
# the 0.0/0.0 that kb/kf becomes when kf underflows, which is NaN in C and silently
# poisons the sensitivity matrix. Every reactor marks an irreversible reaction with
# Keq = inf, which passes this test and correctly gives C/inf = 0. A reversible
# reaction cannot have Keq == 0 (get_equilibrium_constant raises), so the only thing
# that trips this branch is NaN thermochemistry, where zero is the safe answer.
if not (Keq[j] > 0.0):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a reversible reaction Keq can't be 0 (reaction.py:863 raises), so the only thing that trips this branch is NaN thermo. Worth a comment saying so.
The bigger thing: the irreversible sentinel isn't consistent. simple.pyx:277 sets Keq = np.inf, which passes the check and gives C/inf = 0.0. liquid.pyx:168, surface.pyx:315 and mbSampled.pyx:239 have no else, so Keq stays at the 0.0 from np.zeros_like and trips the check instead. Could we add else: self.kb[j] = 0.0; self.Keq[j] = np.inf to those three so they agree by design?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - kb = 0.0; Keq = np.inf added to liquid, surface and mbSampled and kept the guard rather than assuming Keq > 0

rderiv = 0.0
elif ip[j, 1] == -1: # only one product
rderiv = C[ip[j, 0]] / Keq[j]
elif ip[j, 2] == -1: # only two products
rderiv = C[ip[j, 0]] * C[ip[j, 1]] / Keq[j]
else: # three products
rderiv = C[ip[j, 0]] * C[ip[j, 1]] * C[ip[j, 2]] / Keq[j]

flux = fderiv - rderiv
gderiv = rderiv * kf[j] * RT_inverse
Expand Down
3 changes: 3 additions & 0 deletions rmgpy/solver/liquid.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ cdef class LiquidReactor(ReactionSystem):
if rxn.reversible:
self.Keq[j] = rxn.get_equilibrium_constant(self.T.value_si)
self.kb[j] = self.kf[j] / self.Keq[j]
else:
self.kb[j] = 0.0
self.Keq[j] = np.inf

def get_threshold_rate_constants(self, model_settings):
"""
Expand Down
3 changes: 3 additions & 0 deletions rmgpy/solver/mbSampled.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ cdef class MBSampledReactor(ReactionSystem):
if rxn.reversible:
self.Keq[j] = rxn.get_equilibrium_constant(self.T.value_si)
self.kb[j] = self.kf[j] / self.Keq[j]
else:
self.kb[j] = 0.0
self.Keq[j] = np.inf

def set_colliders(self, core_reactions, edge_reactions, core_species):
"""
Expand Down
3 changes: 3 additions & 0 deletions rmgpy/solver/surface.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,9 @@ cdef class SurfaceReactor(ReactionSystem):
# which applies the coverage-dependent correction to Keq at runtime.
self.Keq[j] = rxn.get_equilibrium_constant(self.T.value_si)
self.kb[j] = self.kf[j] / self.Keq[j]
else:
self.kb[j] = 0.0
self.Keq[j] = np.inf

def log_initial_conditions(self, number=None):
"""
Expand Down
Loading
Loading