Files
cantera/test/python/test_jacobian.py
T

1536 lines
57 KiB
Python

import numpy as np
import pytest
from pytest import approx
import cantera as ct
class RateExpressionTests:
"""
Generic test class to check derivatives evaluated for a single reaction within
a reaction mechanism. Derived classes must provide the following fixtures:
- gas: Cantera Solution object representing the gas phase.
- rxn_idx: Index of the reaction to be tested.
- equation: String containing the reaction equation.
- rate_type: String indicating the type of rate expression.
- orders: Dictionary specifying reaction orders (if applicable).
- ix3b: List of species indices involved in three-body interactions (if any).
"""
rtol = 1e-5 # relative tolerance for comparisons
rxn_idx = None
equation = None
rate_type = None
orders = None
ix3b = []
@pytest.fixture(scope='class')
def rxn(self, gas, rxn_idx):
"""Fixture to retrieve the reaction object."""
return gas.reactions()[rxn_idx]
@pytest.fixture(scope='class')
def r_stoich(self, gas):
"""Fixture to retrieve reactant stoichiometric coefficients."""
return gas.reactant_stoich_coeffs
@pytest.fixture(scope='class')
def p_stoich(self, gas):
"""Fixture to retrieve product stoichiometric coefficients."""
return gas.product_stoich_coeffs
@pytest.fixture(scope='class')
def rix(self, gas, rxn):
"""Fixture to retrieve reactant species indices."""
return [gas.species_index(k) for k in rxn.reactants.keys()]
@pytest.fixture(scope='class')
def pix(self, gas, rxn):
"""Fixture to retrieve product species indices."""
return [gas.species_index(k) for k in rxn.products.keys()]
@pytest.fixture(scope='class')
def rxn_idx(self, test_data):
return test_data["rxn_idx"]
@pytest.fixture(scope='class')
def equation(self, test_data):
return test_data["equation"]
@pytest.fixture(scope='class')
def rate_type(self, test_data):
return test_data["rate_type"]
@pytest.fixture(scope='class')
def ix3b(self, test_data):
"""Fixture to retrieve species indices involved in three-body interactions."""
return test_data.get("ix3b", [])
@pytest.fixture(scope='class')
def orders(self, test_data):
"""Fixture to retrieve reaction orders."""
return test_data.get("orders", None)
@pytest.fixture(scope='class')
def setup_rate_expression_tests(self, gas, r_stoich, p_stoich, rxn):
"""
Sets the TPX state for the gas object and provides stoichiometric coefficients
and species indices as well as setting test-specific attributes. Runs once
per class.
"""
tpx = gas.TPX
return {
"tpx": tpx,
"r_stoich": r_stoich,
"p_stoich": p_stoich
}
@pytest.fixture(scope='function', autouse=True)
def setup_rate_expression_data(self, gas, setup_rate_expression_tests, rxn_idx, rxn):
"""
Resets the gas state before each test and configures reaction multipliers.
Also verifies stoichiometric coefficients.
"""
data = setup_rate_expression_tests
tpx = data["tpx"]
r_stoich = data["r_stoich"]
p_stoich = data["p_stoich"]
# Reset gas phase
gas.TPX = tpx
gas.set_multiplier(0.0)
gas.set_multiplier(1.0, rxn_idx)
gas.derivative_settings = {}
# Check stoichiometric coefficients for reactants
for k, v in rxn.reactants.items():
ix = gas.species_index(k)
actual = r_stoich[ix, rxn_idx]
assert actual == v, f"Reactant stoich mismatch for species '{k}'"
# Check stoichiometric coefficients for products
for k, v in rxn.products.items():
ix = gas.species_index(k)
actual = p_stoich[ix, rxn_idx]
assert actual == v, f"Product stoich mismatch for species '{k}'"
def test_input(self, equation, rate_type, rxn):
"""Ensure that correct equation is referenced"""
assert equation == rxn.equation
assert rate_type == rxn.rate.type
def rop_derivs(self, gas, spc_ix, mode, const_t=True, rtol_deltac=1e-5, atol_deltac=1e-20, ddX=True):
"""
Numerical derivative for rates-of-progress with respect to mole fractions
"""
def calc():
if mode == "forward":
return gas.forward_rates_of_progress
if mode == "reverse":
return gas.reverse_rates_of_progress
if mode == "net":
return gas.net_rates_of_progress
tpx = gas.TPX
rop0 = calc()
conc = gas.concentrations
ctot0 = conc.sum()
# perturb concentration
dconc = conc[spc_ix] * rtol_deltac + atol_deltac
conc[spc_ix] += dconc
ctot1 = conc.sum()
if const_t:
# adjust pressure to compensate for concentration change
pnew = gas.P * ctot1 / ctot0
gas.TPX = gas.T, pnew, conc / ctot1
else:
# adjust temperature to compensate for concentration change
tnew = gas.T * ctot1 / ctot0
gas.TPX = tnew, gas.P, conc / ctot1
drop = (calc() - rop0) / dconc
gas.TPX = tpx # reset state
if ddX:
return drop * gas.density_mole
else:
return drop
def test_forward_rop_ddX(self, gas, r_stoich, rxn_idx, rix, rxn, ix3b, orders):
# check derivatives of forward rates of progress with respect to mole fractions
# against analytic result
dropm = gas.forward_rates_of_progress_ddX
dropp = gas.forward_rates_of_progress_ddP
gas.derivative_settings = {"skip-third-bodies": True}
drop = gas.forward_rates_of_progress_ddX
rop = gas.forward_rates_of_progress
for spc_ix in rix:
if orders is None:
order = r_stoich[spc_ix, rxn_idx]
else:
order = orders[gas.species_names[spc_ix]]
assert rop[rxn_idx] == approx(
drop[rxn_idx, spc_ix] * gas.X[spc_ix] / order)
drop_num = self.rop_derivs(gas, spc_ix, mode="forward")
assert dropm[:, spc_ix] + dropp * gas.P == approx(drop_num, rel=self.rtol)
if isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
for spc_ix in set(rix + ix3b):
assert dropm[rxn_idx, spc_ix] # non-zero
dropm[rxn_idx, spc_ix] = 0
assert not dropm.any()
def test_reverse_rop_ddX(self, gas, rxn_idx, p_stoich, pix, rxn, ix3b):
# check derivatives of reverse rates of progress with respect to mole fractions
# against analytic result
dropm = gas.reverse_rates_of_progress_ddX
dropp = gas.reverse_rates_of_progress_ddP
gas.derivative_settings = {"skip-third-bodies": True}
drop = gas.reverse_rates_of_progress_ddX
rop = gas.reverse_rates_of_progress
for spc_ix in pix:
order = p_stoich[spc_ix, rxn_idx]
assert rop[rxn_idx] == approx(
drop[rxn_idx, spc_ix] * gas.X[spc_ix] / order)
drop_num = self.rop_derivs(gas, spc_ix, mode="reverse")
assert dropm[:, spc_ix] + dropp * gas.P == approx(drop_num, rel=self.rtol)
if not rxn.reversible or isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
for spc_ix in set(pix + ix3b):
assert dropm[rxn_idx, spc_ix] # non-zero
dropm[rxn_idx, spc_ix] = 0
assert not dropm.any()
def test_net_rop_ddX(self, gas, rxn_idx, rix, pix, rxn, ix3b):
# check derivatives of net rates of progress with respect to mole fractions
# against numeric result
drop = gas.net_rates_of_progress_ddX
dropp = gas.net_rates_of_progress_ddP
for spc_ix in rix + pix:
drop_num = self.rop_derivs(gas, spc_ix, mode="net")
ix = drop[:, spc_ix] != 0
drop_ = drop[:, spc_ix] + dropp * gas.P
assert drop_[ix] == approx(drop_num[ix], rel=self.rtol)
if not rxn.reversible or isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
for spc_ix in set(rix + pix + ix3b):
assert drop[rxn_idx, spc_ix] # non-zero
drop[rxn_idx, spc_ix] = 0
assert not drop.any()
def test_forward_rop_ddCi(self, gas, rxn_idx, r_stoich, rix, rxn, ix3b, orders):
# check derivatives of forward rates of progress with respect to species
# concentrations against analytic result
dropm = gas.forward_rates_of_progress_ddCi
dropp = gas.forward_rates_of_progress_ddP
gas.derivative_settings = {"skip-third-bodies": True}
drop = gas.forward_rates_of_progress_ddCi
rop = gas.forward_rates_of_progress
for spc_ix in rix:
if orders is None:
order = r_stoich[spc_ix, rxn_idx]
else:
order = orders[gas.species_names[spc_ix]]
assert rop[rxn_idx] == approx(
drop[rxn_idx, spc_ix] * gas.concentrations[spc_ix] / order)
drop_num = self.rop_derivs(gas, spc_ix, mode="forward", ddX=False)
assert dropm[:, spc_ix] + dropp * gas.P == approx(drop_num, rel=1e-3)
if isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
for spc_ix in set(rix + ix3b):
assert dropm[rxn_idx, spc_ix] # non-zero
dropm[rxn_idx, spc_ix] = 0
assert not dropm.any()
def test_reverse_rop_ddCi(self, gas, rxn_idx, r_stoich, pix, p_stoich, rix, rxn, ix3b, orders):
# check derivatives of reverse rates of progress with respect to species
# concentrations against analytic result
dropm = gas.reverse_rates_of_progress_ddCi
dropp = gas.reverse_rates_of_progress_ddP
gas.derivative_settings = {"skip-third-bodies": True}
drop = gas.reverse_rates_of_progress_ddCi
rop = gas.reverse_rates_of_progress
for spc_ix in pix:
order = p_stoich[spc_ix, rxn_idx]
assert rop[rxn_idx] == approx(
drop[rxn_idx, spc_ix] * gas.concentrations[spc_ix] / order)
drop_num = self.rop_derivs(gas, spc_ix, mode="reverse", ddX=False)
assert dropm[:, spc_ix] + dropp * gas.P == approx(drop_num, rel=1e-3)
if not rxn.reversible or isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
for spc_ix in set(pix + ix3b):
assert dropm[rxn_idx, spc_ix] # non-zero
dropm[rxn_idx, spc_ix] = 0
assert not dropm.any()
def test_net_rop_ddCi(self, gas, rxn_idx, r_stoich, rix, pix, rxn, ix3b, orders):
# check derivatives of net rates of progress with respect to species
# concentrations against numeric result
drop = gas.net_rates_of_progress_ddCi
dropp = gas.net_rates_of_progress_ddP
for spc_ix in rix + pix:
drop_num = self.rop_derivs(gas, spc_ix, mode="net", ddX=False)
ix = drop[:, spc_ix] != 0
drop_ = drop[:, spc_ix] + dropp * gas.P
assert drop_[ix] == approx(drop_num[ix], rel=1e-4)
if not rxn.reversible or isinstance(rxn.rate, ct.FalloffRate):
return
def rop_ddT(self, gas, rxn_idx, mode=None, const_p=False, rtol=1e-6):
# numerical derivative for rates-of-progress at constant pressure
def calc():
if mode == "forward":
return gas.forward_rates_of_progress
if mode == "reverse":
return gas.reverse_rates_of_progress
if mode == "net":
return gas.net_rates_of_progress
tpx = gas.TPX
dt = tpx[0] * rtol
dp = 0 if const_p else tpx[1] * rtol
gas.TP = tpx[0] + dt, tpx[1] + dp
rop1 = calc()
gas.TP = tpx[:2]
rop0 = calc()
gas.TPX = tpx
return (rop1[rxn_idx] - rop0[rxn_idx]) / dt
def test_forward_rop_ddT(self, gas, rxn_idx, rxn):
# check derivatives of forward rop with respect to temperature
# constant pressure - need to account for density change
dcdt = - gas.density_mole / gas.T
drop = gas.forward_rates_of_progress_ddT
drop += gas.forward_rates_of_progress_ddC * dcdt
drop_num = self.rop_ddT(gas, rxn_idx, mode="forward", const_p=True)
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
# constant density (volume) - need to account for pressure change
dpdt = gas.P / gas.T
drop = gas.forward_rates_of_progress_ddT
drop += gas.forward_rates_of_progress_ddP * dpdt
drop_num = self.rop_ddT(gas, rxn_idx, mode="forward")
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
if isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
assert drop[rxn_idx] # non-zero
drop[rxn_idx] = 0
assert not drop.any()
def test_reverse_rop_ddT(self, gas, rxn_idx, rxn):
# check derivatives of reverse rop with respect to temperature
# constant pressure - need to account for density change
dcdt = - gas.density_mole / gas.T
drop = gas.reverse_rates_of_progress_ddT
drop += gas.reverse_rates_of_progress_ddC * dcdt
drop_num = self.rop_ddT(gas, rxn_idx, mode="reverse", const_p=True)
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
# constant density (volume) - need to account for pressure change
dpdt = gas.P / gas.T
drop = gas.reverse_rates_of_progress_ddT
drop += gas.reverse_rates_of_progress_ddP * dpdt
drop_num = self.rop_ddT(gas, rxn_idx, mode="reverse")
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
if not rxn.reversible or isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
assert drop[rxn_idx] # non-zero
drop[rxn_idx] = 0
assert not drop.any()
def test_net_rop_ddT(self, gas, rxn_idx, rxn):
# check derivatives of net rop with respect to temperature
# constant pressure - need to account for density change
dcdt = - gas.density_mole / gas.T
drop = gas.net_rates_of_progress_ddT
drop += gas.net_rates_of_progress_ddC * dcdt
drop_num = self.rop_ddT(gas, rxn_idx, mode="net", const_p=True)
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
# constant density (volume) - need to account for pressure change
dpdt = gas.P / gas.T
drop = gas.net_rates_of_progress_ddT
drop += gas.net_rates_of_progress_ddP * dpdt
drop_num = self.rop_ddT(gas, rxn_idx, mode="forward") - self.rop_ddT(gas, rxn_idx, mode="reverse")
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
if not rxn.reversible or isinstance(rxn.rate, ct.FalloffRate):
return
# ensure all zeros are in the correct spots
assert drop[rxn_idx] # non-zero
drop[rxn_idx] = 0
assert not drop.any()
def rop_ddP(self, gas, rxn_idx, mode=None, rtol=1e-6):
# numerical derivative for rates-of-progress at constant pressure
def calc():
if mode == "forward":
return gas.forward_rates_of_progress
if mode == "reverse":
return gas.reverse_rates_of_progress
if mode == "net":
return gas.net_rates_of_progress
tpx = gas.TPX
dp = tpx[1] * rtol
gas.TP = tpx[0], tpx[1] + dp
rop1 = calc()
gas.TP = tpx[:2]
rop0 = calc()
return (rop1[rxn_idx] - rop0[rxn_idx]) / dp
def test_forward_rop_ddP(self, gas, rxn_idx):
# check derivatives of forward rop with respect to pressure
# constant temperature - need to account for density change
dcdp = gas.density_mole / gas.P
drop = gas.forward_rates_of_progress_ddP
drop += gas.forward_rates_of_progress_ddC * dcdp
drop_num = self.rop_ddP(gas, rxn_idx, mode="forward")
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
def test_reverse_rop_ddP(self, gas, rxn_idx):
# check derivatives of reverse rop with respect to pressure
# constant temperature - need to account for density change
dcdp = gas.density_mole / gas.P
drop = gas.reverse_rates_of_progress_ddP
drop += gas.reverse_rates_of_progress_ddC * dcdp
drop_num = self.rop_ddP(gas, rxn_idx, mode="reverse")
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
def test_net_rop_ddP(self, gas, rxn_idx):
# check derivatives of net rop with respect to pressure
# constant temperature - need to account for density change
dcdp = gas.density_mole / gas.P
drop = gas.net_rates_of_progress_ddP
drop += gas.net_rates_of_progress_ddC * dcdp
drop_num = self.rop_ddP(gas, rxn_idx, mode="net")
assert drop[rxn_idx] == approx(drop_num, rel=self.rtol)
def rate_ddT(self, gas, mode=None, const_p=False, rtol=1e-6):
# numerical derivative for production rates with respect to temperature
def calc():
if mode == "creation":
return gas.creation_rates
if mode == "destruction":
return gas.destruction_rates
if mode == "net":
return gas.net_production_rates
tpx = gas.TPX
dt = tpx[0] * rtol
dp = 0 if const_p else tpx[1] * rtol
gas.TP = tpx[0] + dt, tpx[1] + dp
rate1 = calc()
gas.TP = tpx[:2]
rate0 = calc()
gas.TPX = tpx
return (rate1 - rate0) / dt
def test_net_rate_ddT(self, gas, rix, pix):
# check equivalence of numerical and analytical derivatives of net creation
# rates with respect to temperature
# constant pressure - need to account for density change
# numeric: d(omegadot)/dT =
# analytic: d(omegadot)/dT + dC/dT d(omegadot)/dC
dcdt = - gas.density_mole / gas.T
drate = gas.net_production_rates_ddT
drate += gas.net_production_rates_ddC * dcdt
drate_num = self.rate_ddT(gas, mode="net", const_p=True)
for spc_ix in rix + pix:
assert drate[spc_ix] == approx(drate_num[spc_ix], rel=self.rtol)
# constant density (volume) - need to account for pressure change
# numeric: d(omegadot)/dT =
# analytic: d(omegadot)/dT + dP/dT d(omegadot)/dP
dpdt = gas.P / gas.T
drate = gas.net_production_rates_ddT
drate += gas.net_production_rates_ddP * dpdt
drate_num = self.rate_ddT(gas, mode="creation") - self.rate_ddT(gas, mode="destruction")
for spc_ix in rix + pix:
assert drate[spc_ix] == approx(drate_num[spc_ix], rel=self.rtol)
def rate_ddX(self, gas, spc_ix, mode=None, const_t=True, rtol_deltac=1e-6,
atol_deltac=1e-20, ddX=True):
# numerical derivative for production rates with respect to mole fractions
def calc(mode):
if mode == "creation":
return gas.creation_rates
if mode == "destruction":
return gas.destruction_rates
if mode == "net":
return gas.net_production_rates
tpx = gas.TPX
rate0 = calc(mode)
conc = gas.concentrations
ctot0 = conc.sum()
# perturb concentration
dconc = conc[spc_ix] * rtol_deltac + atol_deltac
conc[spc_ix] += dconc
ctot1 = conc.sum()
if const_t:
# adjust pressure to compensate for concentration change
pnew = gas.P * ctot1 / ctot0
gas.TPX = gas.T, pnew, conc / ctot1
else:
# adjust temperature to compensate for concentration change
tnew = gas.T * ctot1 / ctot0
gas.TPX = tnew, gas.P, conc / ctot1
drate = (calc(mode) - rate0) / dconc
gas.TPX = tpx # reset state
# cantera calculates kinetics derivatives with respect to mole fractions
# and concentrations, when ddX flag is true it will return the numerical
# derivatives in the form of mole fractions but otherwise return concentrations
if ddX:
return drate * gas.density_mole
else:
return drate
def test_creation_ddX(self, gas, rix, pix):
# check derivatives of creation rates with respect to mole fractions
drate = gas.creation_rates_ddX
dratep = gas.creation_rates_ddP
for spc_ix in rix + pix:
drate_num = self.rate_ddX(gas, spc_ix, "creation")
ix = drate[:, spc_ix] != 0
drate[:, spc_ix] += dratep * gas.P
assert drate[ix, spc_ix] == approx(drate_num[ix], rel=self.rtol)
def test_destruction_ddX(self, gas, rix, pix):
# check derivatives of destruction rates with respect to mole fractions
drate = gas.destruction_rates_ddX
dratep = gas.destruction_rates_ddP
for spc_ix in rix + pix:
drate_num = self.rate_ddX(gas, spc_ix, "destruction")
ix = drate[:, spc_ix] != 0
drate[:, spc_ix] += dratep * gas.P
assert drate[ix, spc_ix] == approx(drate_num[ix], rel=self.rtol)
def test_net_production_ddX(self, gas, rix, pix):
# check derivatives of destruction rates with respect to mole fractions
drate = gas.net_production_rates_ddX
dratep = gas.net_production_rates_ddP
for spc_ix in rix + pix:
drate_num = self.rate_ddX(gas, spc_ix, "net")
ix = drate[:, spc_ix] != 0
drate[:, spc_ix] += dratep * gas.P
assert drate[ix, spc_ix] == approx(drate_num[ix], rel=self.rtol)
def test_creation_ddCi(self, gas, rix, pix):
# check derivatives of creation rates with respect to mole fractions
drate = gas.creation_rates_ddCi
dratep = gas.creation_rates_ddP
for spc_ix in rix + pix:
drate_num = self.rate_ddX(gas, spc_ix, "creation", ddX=False)
ix = drate[:, spc_ix] != 0
drate[:, spc_ix] += dratep * gas.P
assert drate[ix, spc_ix] == approx(drate_num[ix], rel=1e-3)
def test_destruction_ddCi(self, gas, rix, pix):
# check derivatives of destruction rates with respect to mole fractions
drate = gas.destruction_rates_ddCi
dratep = gas.destruction_rates_ddP
for spc_ix in rix + pix:
drate_num = self.rate_ddX(gas, spc_ix, "destruction", ddX=False)
ix = drate[:, spc_ix] != 0
drate[:, spc_ix] += dratep * gas.P
assert drate[ix, spc_ix] == approx(drate_num[ix], rel=1e-3)
def test_net_production_ddCi(self, gas, rix, pix):
# check derivatives of destruction rates with respect to mole fractions
drate = gas.net_production_rates_ddCi
dratep = gas.net_production_rates_ddP
for spc_ix in rix + pix:
drate_num = self.rate_ddX(gas, spc_ix, "net", ddX=False)
ix = drate[:, spc_ix] != 0
# drate[:, spc_ix] += dratep * gas.P
assert drate[ix, spc_ix] == approx(drate_num[ix], rel=1e-3)
class HydrogenOxygen(RateExpressionTests):
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution("h2o2.yaml", transport_model=None)
# species: [H2, H, O, O2, OH, H2O, HO2, H2O2, AR, N2]
gas.X = [0.1, 1e-4, 1e-5, 0.2, 2e-4, 0.3, 1e-6, 5e-5, 0.3, 0.1]
gas.TP = 800, 2 * ct.one_atm
return gas
class TestElementaryRev(HydrogenOxygen):
"""Standard elementary reaction with two reactants"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 2,
"equation": "H2 + O <=> H + OH",
"rate_type": "Arrhenius"
}
class TestElementarySelf(HydrogenOxygen):
"""Elementary reaction with reactant reacting with itself"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 27,
"equation": "2 HO2 <=> H2O2 + O2",
"rate_type": "Arrhenius"
}
class TestFalloff(HydrogenOxygen):
""" Fall-off reaction"""
rtol = 2e-4
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 21,
"equation": "2 OH (+M) <=> H2O2 (+M)",
"rate_type": "falloff"
}
class TestThreeBody(HydrogenOxygen):
""" Three-body reaction with default efficiency"""
@pytest.fixture(scope='class')
def test_data(self, gas):
return {
"rxn_idx": 1,
"equation": "H + O + M <=> OH + M",
"rate_type": "Arrhenius",
"ix3b": list(range(gas.n_species))
}
def test_thirdbodies_forward(self, gas, rxn_idx):
drop = gas.forward_rates_of_progress_ddX
gas.derivative_settings = {"skip-third-bodies": True}
drops = gas.forward_rates_of_progress_ddX
dropm = drop - drops
rop = gas.forward_rates_of_progress
assert rop[rxn_idx] == approx((dropm[rxn_idx] * gas.X).sum())
def test_thirdbodies_reverse(self, gas, rxn_idx):
drop = gas.reverse_rates_of_progress_ddX
gas.derivative_settings = {"skip-third-bodies": True}
drops = gas.reverse_rates_of_progress_ddX
dropm = drop - drops
rop = gas.reverse_rates_of_progress
assert rop[rxn_idx] == approx((dropm[rxn_idx] * gas.X).sum())
class EdgeCases(RateExpressionTests):
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution("jacobian-tests.yaml", transport_model=None)
# species: [H2, H, O, O2, OH, H2O, HO2, H2O2, AR]
gas.X = [0.1, 1e-4, 1e-5, 0.2, 2e-4, 0.3, 1e-6, 5e-5, 0.4]
gas.TP = 800, 2 * ct.one_atm
return gas
class TestElementaryIrr(EdgeCases):
"""Irreversible elementary reaction with two reactants"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 0,
"equation": "HO2 + O => O2 + OH",
"rate_type": "Arrhenius"
}
class TestElementaryOne(EdgeCases):
"""Three-body reaction with single reactant species"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 1,
"equation": "H2 <=> 2 H",
"rate_type": "Arrhenius"
}
class TestElementaryThree(EdgeCases):
"""Elementary reaction with three reactants"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 2,
"equation": "2 H + O <=> H2O",
"rate_type": "Arrhenius"
}
class TestElementaryFrac(EdgeCases):
"""Elementary reaction with specified reaction order"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 3,
"equation": "0.7 H2 + 0.2 O2 + 0.6 OH => H2O",
"rate_type": "Arrhenius",
"orders": {"H2": 0.8, "O2": 1.0, "OH": 2.0}
}
class TestThreeBodyNoDefault(EdgeCases):
"""Three body reaction without default efficiency"""
@pytest.fixture(scope='class')
def test_data(self, gas):
efficiencies = {"H2": 2.0, "H2O": 6.0, "AR": 0.7}
return {
"rxn_idx": 4,
"equation": "H + O + M <=> OH + M",
"rate_type": "Arrhenius",
"ix3b": [gas.species_index(k) for k in efficiencies.keys()]
}
class FromScratchCases(RateExpressionTests):
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution("kineticsfromscratch.yaml", transport_model=None)
# species: [AR, O, H2, H, OH, O2, H2O, H2O2, HO2]
gas.X = [0.1, 3e-4, 5e-5, 6e-6, 3e-3, 0.6, 0.25, 1e-6, 2e-5]
gas.TP = 2000, 5 * ct.one_atm
return gas
@pytest.mark.usefixtures("has_temperature_derivative_warnings")
def test_forward_rop_ddT(self, gas, rxn_idx, rxn):
"""Override to handle temperature derivative warnings."""
super().test_forward_rop_ddT(gas, rxn_idx, rxn)
@pytest.mark.usefixtures("has_temperature_derivative_warnings")
def test_reverse_rop_ddT(self, gas, rxn_idx, rxn):
"""Override to handle temperature derivative warnings."""
super().test_reverse_rop_ddT(gas, rxn_idx, rxn)
@pytest.mark.usefixtures("has_temperature_derivative_warnings")
def test_net_rop_ddT(self, gas, rxn_idx, rxn):
"""Override to handle temperature derivative warnings."""
super().test_net_rop_ddT(gas, rxn_idx, rxn)
@pytest.mark.usefixtures("has_temperature_derivative_warnings")
def test_net_rate_ddT(self, gas, rix, pix):
"""Override to handle temperature derivative warnings."""
super().test_net_rate_ddT(gas, rix, pix)
class TestPlog(FromScratchCases):
""" Plog reaction"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 3,
"equation": "H2 + O2 <=> 2 OH",
"rate_type": "pressure-dependent-Arrhenius"
}
class TestChebyshev(FromScratchCases):
"""Chebyshev reaction"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 4,
"equation": "HO2 <=> O + OH",
"rate_type": "Chebyshev"
}
class TestBlowersMasel(FromScratchCases):
"""Blowers-Masel"""
@pytest.fixture(scope='class')
def test_data(self):
return {
"rxn_idx": 6,
"equation": "H2 + O <=> H + OH",
"rate_type": "Blowers-Masel"
}
@pytest.mark.xfail(reason="Change of reaction enthalpy is not considered")
@pytest.mark.filterwarnings("ignore:.*does not consider.*(electron|enthalpy).*:UserWarning")
def test_forward_rop_ddT(self, gas, rxn_idx, rxn):
"""Override to handle issues with Blowers-Masel derivatives."""
super().test_forward_rop_ddT(gas, rxn_idx, rxn)
@pytest.mark.xfail(reason="Change of reaction enthalpy is not considered")
@pytest.mark.filterwarnings("ignore:.*does not consider.*(electron|enthalpy).*:UserWarning")
def test_reverse_rop_ddT(self, gas, rxn_idx, rxn):
"""Override to handle issues with Blowers-Masel derivatives."""
super().test_reverse_rop_ddT(gas, rxn_idx, rxn)
@pytest.mark.xfail(reason="Change of reaction enthalpy is not considered")
@pytest.mark.filterwarnings("ignore:.*does not consider.*(electron|enthalpy).*:UserWarning")
def test_net_rop_ddT(self, gas, rxn_idx, rxn):
"""Override to handle issues with Blowers-Masel derivatives."""
super().test_net_rop_ddT(gas, rxn_idx, rxn)
@pytest.mark.xfail(reason="Change of reaction enthalpy is not considered")
@pytest.mark.filterwarnings("ignore:.*does not consider.*(electron|enthalpy).*:UserWarning")
def test_net_rate_ddT(self, gas, rix, pix):
"""Override to handle issues with Blowers-Masel derivatives."""
super().test_net_rate_ddT(gas, rix, pix)
class FullTests:
"""
Generic test class to check derivatives evaluated for an entire reaction mechanisms
"""
rtol = 1e-4
@pytest.fixture(scope='class')
def initial_conditions(self, gas):
"""Store initial TPX conditions for gas."""
return gas.TPX
@pytest.fixture(scope='class', autouse=True)
def setup_full_tests(self, gas, initial_conditions):
"""
Equilibrate the gas phase at constant enthalpy and pressure.
"""
gas.equilibrate("HP")
@pytest.fixture(scope='function', autouse=True)
def full_tests_data(self, gas, initial_conditions):
"""
Reset TPX and derivative settings before each test.
"""
gas.TPX = initial_conditions
gas.derivative_settings = {} # Reset to defaults
def rop_derivs(self, gas, mode, rtol_deltac=1e-9, atol_deltac=1e-20, ddX=True):
# numerical derivative for rates-of-progress with respect to mole fractions
def calc():
if mode == "forward":
return gas.forward_rates_of_progress
if mode == "reverse":
return gas.reverse_rates_of_progress
if mode == "net":
return gas.net_rates_of_progress
n_spc, n_rxn = gas.n_species, gas.n_reactions
drop = np.zeros((n_rxn, n_spc))
tpx = gas.TPX
rop0 = calc()
ctot0 = gas.density_mole
ctot0 = gas.concentrations.sum()
for spc_ix in range(n_spc):
conc = gas.concentrations
dconc = conc[spc_ix] * rtol_deltac + atol_deltac
conc[spc_ix] += dconc
ctot1 = conc.sum()
gas.TPX = tpx[0], tpx[1] * ctot1 / ctot0, conc / ctot1
drop[:, spc_ix] = (calc() - rop0) / dconc
gas.TPX = tpx
if ddX:
return drop * gas.density_mole
else:
return drop
def test_forward_rop_ddX(self, gas):
"""
Check forward rop against numerical derivative with respect to mole fractions
"""
drop = gas.forward_rates_of_progress_ddX
dropp = gas.forward_rates_of_progress_ddP
drop_num = self.rop_derivs(gas, mode="forward")
stoich = gas.reactant_stoich_coeffs
for i in range(gas.n_reactions):
try:
# test entries that are not spurious
ix = np.abs((stoich[:, i] != 0) * drop[i, :]) > 1e-6
drop_ = drop[i, ix] + dropp[i] * gas.P
assert drop_ == approx(drop_num[i, ix], rel=self.rtol)
except AssertionError as err:
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(np.vstack([drop[i, ix], drop_num[i, ix]]).T)
raise err
def test_reverse_rop_ddX(self, gas):
"""
Check reverse rop against numerical derivative with respect to mole fractions
"""
drop = gas.reverse_rates_of_progress_ddX
dropp = gas.reverse_rates_of_progress_ddP
drop_num = self.rop_derivs(gas, mode="reverse")
stoich = gas.product_stoich_coeffs
for i in range(gas.n_reactions):
try:
# test entries that are not spurious
ix = np.abs((stoich[:, i] != 0) * drop[i, :]) > 1e-6
drop_ = drop[i, ix] + dropp[i] * gas.P
assert drop_ == approx(drop_num[i, ix], rel=self.rtol)
except AssertionError as err:
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(np.vstack([drop[i, ix], drop_num[i, ix]]).T)
raise err
def test_net_rop_ddX(self, gas):
"""
Check net rop against numerical derivative with respect to mole fractions
"""
drop = gas.net_rates_of_progress_ddX
dropp = gas.net_rates_of_progress_ddP
drop_num = self.rop_derivs(gas, mode="net")
stoich = gas.product_stoich_coeffs - gas.reactant_stoich_coeffs
for i in range(gas.n_reactions):
try:
# test entries that are not spurious
ix = np.abs((stoich[:, i] != 0) * drop[i, :]) > 1e-6
drop_ = drop[i, ix] + dropp[i] * gas.P
assert drop_ == approx(drop_num[i, ix], rel=self.rtol)
except AssertionError as err:
if gas.reaction(i).reversible:
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(np.vstack([drop[i, ix], drop_num[i, ix]]).T)
raise err
def test_forward_rop_ddCi(self, gas):
"""
Check forward rop against numerical derivative with respect to species
concentrations
"""
drop = gas.forward_rates_of_progress_ddCi
dropp = gas.forward_rates_of_progress_ddP
drop_num = self.rop_derivs(gas, mode="forward", ddX=False)
stoich = gas.reactant_stoich_coeffs
for i in range(gas.n_reactions):
try:
# test entries that are not spurious
ix = np.abs((stoich[:, i] != 0) * drop[i, :]) > 1e-6
drop_ = drop[i, ix] + dropp[i] * gas.P
assert drop_ == approx(drop_num[i, ix], rel=self.rtol)
except AssertionError as err:
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(np.vstack([drop[i, ix], drop_num[i, ix]]).T)
raise err
def test_reverse_rop_ddCi(self, gas):
"""
Check reverse rop against numerical derivative with respect to species
concentrations
"""
drop = gas.reverse_rates_of_progress_ddCi
dropp = gas.reverse_rates_of_progress_ddP
drop_num = self.rop_derivs(gas, mode="reverse", ddX=False)
stoich = gas.product_stoich_coeffs
for i in range(gas.n_reactions):
try:
# test entries that are not spurious
ix = np.abs((stoich[:, i] != 0) * drop[i, :]) > 1e-6
drop_ = drop[i, ix] + dropp[i] * gas.P
assert drop_ == approx(drop_num[i, ix], rel=self.rtol)
except AssertionError as err:
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(np.vstack([drop[i, ix], drop_num[i, ix]]).T)
raise err
def test_net_rop_ddCi(self, gas):
"""
Check net rop against numerical derivative with respect to species
concentrations
"""
drop = gas.net_rates_of_progress_ddCi
dropp = gas.net_rates_of_progress_ddP
drop_num = self.rop_derivs(gas, mode="net", ddX=False)
stoich = gas.product_stoich_coeffs - gas.reactant_stoich_coeffs
for i in range(gas.n_reactions):
try:
# test entries that are not spurious
ix = np.abs((stoich[:, i] != 0) * drop[i, :]) > 1e-6
drop_ = drop[i, ix] + dropp[i] * gas.P
assert drop_ == approx(drop_num[i, ix], rel=self.rtol)
except AssertionError as err:
if gas.reaction(i).reversible:
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(np.vstack([drop[i, ix], drop_num[i, ix]]).T)
raise err
def rop_ddT(self, gas, mode=None, dt=1e-6):
"""Numerical derivative for rates-of-progress at constant pressure"""
def calc():
if mode == "forward":
return gas.forward_rates_of_progress
if mode == "reverse":
return gas.reverse_rates_of_progress
if mode == "net":
return gas.net_rates_of_progress
return None
tpx = gas.TPX
gas.TP = tpx[0] + dt, tpx[1]
rop1 = calc()
gas.TP = tpx[:2]
rop0 = calc()
gas.TPX = tpx
return (rop1 - rop0) / dt
def test_forward_rop_ddT(self, gas):
"""
Check forward rop against numerical derivative with respect to temperature
"""
dcdt = - gas.density_mole / gas.T
drop = gas.forward_rates_of_progress_ddT
drop += gas.forward_rates_of_progress_ddC * dcdt
drop_num = self.rop_ddT(gas, mode="forward")
assert drop == approx(drop_num, rel=self.rtol)
def test_reverse_rop_ddT(self, gas):
"""
Check reverse rop against numerical derivative with respect to temperature
"""
dcdt = - gas.density_mole / gas.T
drop = gas.reverse_rates_of_progress_ddT
drop += gas.reverse_rates_of_progress_ddC * dcdt
drop_num = self.rop_ddT(gas, mode="reverse")
assert drop == approx(drop_num, rel=self.rtol)
def test_net_rop_ddT(self, gas):
"""
Check net rop against numerical derivative with respect to temperature
"""
dcdt = - gas.density_mole / gas.T
drop = gas.net_rates_of_progress_ddT
drop += gas.net_rates_of_progress_ddC * dcdt
drop_num = self.rop_ddT(gas, mode="net")
try:
assert drop == approx(drop_num, rel=self.rtol)
except AssertionError as err:
i = np.argmax(2 * (drop - drop_num) / (drop + drop_num + 2e-4))
print(i, gas.reaction(i).rate.type)
print(gas.reaction(i))
print(drop[i])
print(drop_num[i])
raise err
class TestFullHydrogenOxygen(FullTests):
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution("h2o2.yaml", transport_model=None)
gas.TPX = 300, 5 * ct.one_atm, "H2:1, O2:3"
return gas
class TestFullGriMech(FullTests):
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution("gri30.yaml", transport_model=None)
gas.TPX = 300, 5 * ct.one_atm, "CH4:1, C3H8:.1, O2:1, N2:3.76"
return gas
class TestFullEdgeCases(FullTests):
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution("jacobian-tests.yaml", transport_model=None)
# species: [H2, H, O, O2, OH, H2O, HO2, H2O2, AR]
gas.TPX = 300, 2 * ct.one_atm, "H2:1, O2:3, AR:0.4"
return gas
class SurfaceRateExpressionTests:
"""
Generic test class to check derivatives evaluated for a single reaction within
a reaction mechanism for surfaces.
"""
rtol = 1e-5 # Relative tolerance for approximate comparisons
@pytest.fixture(scope='class')
def all_species(self, gas, surf):
"""Combine species from both gas and surface phases."""
return surf.species() + gas.species()
@pytest.fixture(scope='class')
def sidxs(self, all_species):
"""Create a dictionary mapping species names to indices."""
return {spec.name: i for i, spec in enumerate(all_species)}
@pytest.fixture(scope='class')
def rxn_data(self, surf, sidxs, rxn_idx):
"""
Retrieve reaction-specific data based on the reaction index.
Returns a dictionary containing reaction, reactant indices, and product indices.
"""
rxn = surf.reactions()[rxn_idx]
r_stoich = surf.reactant_stoich_coeffs
p_stoich = surf.product_stoich_coeffs
rix = [sidxs[k] for k in rxn.reactants.keys()]
pix = [sidxs[k] for k in rxn.products.keys()]
return {
"rxn": rxn,
"r_stoich": r_stoich,
"p_stoich": p_stoich,
"rix": rix,
"pix": pix
}
@pytest.fixture(scope='class')
def initial_conditions(self, gas, surf):
"""Store initial TPX conditions for gas and surface."""
return {
"gas_tpx": gas.TPX,
"surf_tpx": surf.TPX
}
@pytest.fixture(scope='function', autouse=True)
def setup_surface_rate_expression_data(self, gas, surf, initial_conditions, rxn_data, rxn_idx, sidxs):
"""
Reset TPX and derivative settings before each test and configure reaction multipliers.
Also verify stoichiometric coefficients.
"""
# Reset gas phase
gas.TPX = initial_conditions["gas_tpx"]
gas.set_multiplier(0)
gas.derivative_settings = {} # Reset to defaults
# Reset surface phase
surf.TPX = initial_conditions["surf_tpx"]
surf.set_multiplier(0.0)
surf.set_multiplier(1.0, rxn_idx) # Enable multiplier for specific reaction
surf.derivative_settings = {
"skip-coverage-dependence": True,
"skip-electrochemistry": True
}
# Retrieve reaction data
rxn = rxn_data["rxn"]
r_stoich = rxn_data["r_stoich"]
p_stoich = rxn_data["p_stoich"]
# Check stoichiometric coefficients for reactants
for k, v in rxn.reactants.items():
ix = sidxs[k]
actual = r_stoich[ix, rxn_idx]
assert actual == v, f"Reactant stoich mismatch for species '{k}'"
# Check stoichiometric coefficients for products
for k, v in rxn.products.items():
ix = sidxs[k]
actual = p_stoich[ix, rxn_idx]
assert actual == v, f"Product stoich mismatch for species '{k}'"
def get_concentrations(self, surf, gas):
"""Concatenate concentrations from surface and gas phases."""
return np.concatenate((surf.concentrations, gas.concentrations))
# Test methods
def test_input(self, rxn_data, equation, rate_type):
"""Ensure that the correct equation and rate type are referenced."""
rxn = rxn_data["rxn"]
assert equation == rxn.equation
assert rate_type == rxn.rate.type
def test_forward_rop_ddCi(self, surf, gas, rxn_data, orders, rxn_idx):
"""Test forward rates of progress derivatives."""
drop = surf.forward_rates_of_progress_ddCi
rop = surf.forward_rates_of_progress
concentrations = self.get_concentrations(surf, gas)
specs = surf.species_names + gas.species_names
rix = rxn_data["rix"]
r_stoich = rxn_data["r_stoich"]
for spc_ix in rix:
if orders is None:
order = r_stoich[spc_ix, rxn_idx]
else:
order = orders.get(specs[spc_ix], 1)
expected_rop = drop[rxn_idx, spc_ix] * concentrations[spc_ix] / order
assert rop[rxn_idx] == pytest.approx(expected_rop, rel=self.rtol), (
f"Forward ROP derivative mismatch for species index {spc_ix}"
)
def test_reverse_rop_ddCi(self, surf, gas, rxn_data, orders, rxn_idx):
"""Test reverse rates of progress derivatives."""
drop = surf.reverse_rates_of_progress_ddCi
rop = surf.reverse_rates_of_progress
concentrations = self.get_concentrations(surf, gas)
specs = surf.species_names + gas.species_names
pix = rxn_data["pix"]
p_stoich = rxn_data["p_stoich"]
for spc_ix in pix:
if orders is None:
order = p_stoich[spc_ix, rxn_idx]
else:
order = orders.get(specs[spc_ix], 1)
expected_rop = drop[rxn_idx, spc_ix] * concentrations[spc_ix] / order
assert rop[rxn_idx] == pytest.approx(expected_rop, rel=self.rtol), (
f"Reverse ROP derivative mismatch for species index {spc_ix}"
)
def test_net_rop_ddCi(self, surf, gas, rxn_data, rxn_idx, orders):
"""Test net rates of progress derivatives."""
rop = surf.net_rates_of_progress
drop = surf.net_rates_of_progress_ddCi
concentrations = self.get_concentrations(surf, gas)
drop_result = drop @ concentrations
# Adjust forward and reverse rates by reaction orders
ropf = surf.forward_rates_of_progress.copy()
ropr = surf.reverse_rates_of_progress.copy()
rxn = rxn_data["rxn"]
orders_rxn = rxn.orders
# Calculate total reactant orders
total_orders_fwd = sum(orders_rxn.get(k, v) for k, v in rxn.reactants.items())
ropf[rxn_idx] *= total_orders_fwd
# Calculate total product orders
total_orders_rev = sum(orders_rxn.get(k, v) for k, v in rxn.products.items())
ropr[rxn_idx] *= total_orders_rev
expected_net_rop = ropf - ropr
assert drop_result[rxn_idx] == pytest.approx(expected_net_rop[rxn_idx], rel=self.rtol), (
f"Net ROP derivative mismatch for reaction index {rxn_idx}"
)
class PlatinumHydrogen(SurfaceRateExpressionTests):
"""
Derived test class for Platinum-Hydrogen system.
Provides specific fixtures for gas and surface phases.
"""
phase_defs = """
units: {length: cm, quantity: mol, activation-energy: J/mol}
phases:
- name: gas
thermo: ideal-gas
species:
- gri30.yaml/species: [H2, H2O, H2O2, O2]
kinetics: gas
reactions:
- gri30.yaml/reactions: declared-species
skip-undeclared-third-bodies: true
- name: Pt_surf
thermo: ideal-surface
species:
- ptcombust.yaml/species: [PT(S), H(S), H2O(S), OH(S), O(S)]
kinetics: surface
reactions: [ptcombust.yaml/reactions: declared-species]
site-density: 3e-09
"""
@pytest.fixture(scope='class')
def gas(self):
"""Create and configure the gas phase."""
gas = ct.Solution(yaml=self.phase_defs, name="gas")
gas.TPX = 800, 2 * ct.one_atm, "H2:1.5, O2:1.0, H2O2:0.75, H2O:0.3"
return gas
@pytest.fixture(scope='class')
def surf(self, gas):
"""Create and configure the surface phase."""
surf = ct.Interface(yaml=self.phase_defs, name="Pt_surf", adjacent=[gas])
surf.TPX = 800, 2 * ct.one_atm, "PT(S):4.0, H(S):0.5, H2O(S):0.1, OH(S):0.2, O(S):0.8"
return surf
class TestSurfInterfaceArrhenius(PlatinumHydrogen):
"""Test case for interface-Arrhenius reaction H(S) + O(S) <=> OH(S) + PT(S)."""
@pytest.fixture(scope='class')
def rxn_idx(self):
return 7
@pytest.fixture(scope='class')
def equation(self):
return "H(S) + O(S) <=> OH(S) + PT(S)"
@pytest.fixture(scope='class')
def rate_type(self):
return "interface-Arrhenius"
@pytest.fixture(scope='class')
def orders(self):
return None
class TestSurfGasFwdStickingArrhenius(PlatinumHydrogen):
"""Test case for sticking-Arrhenius reaction H2O + PT(S) => H2O(S)."""
@pytest.fixture(scope='class')
def rxn_idx(self):
return 5
@pytest.fixture(scope='class')
def equation(self):
return "H2O + PT(S) => H2O(S)"
@pytest.fixture(scope='class')
def rate_type(self):
return "sticking-Arrhenius"
@pytest.fixture(scope='class')
def orders(self):
return None
class TestSurfGasInterfaceArrhenius(PlatinumHydrogen):
"""Test case for interface-Arrhenius reaction H2 + 2 PT(S) => 2 H(S)."""
@pytest.fixture(scope='class')
def rxn_idx(self):
return 0
@pytest.fixture(scope='class')
def equation(self):
return "H2 + 2 PT(S) => 2 H(S)"
@pytest.fixture(scope='class')
def rate_type(self):
return "interface-Arrhenius"
@pytest.fixture(scope='class')
def orders(self):
return {"PT(S)": 1, "H2": 1, "H(S)": 2}
class TestGasSurfInterfaceArrhenius(PlatinumHydrogen):
"""Test case for interface-Arrhenius reaction H2O(S) => H2O + PT(S)."""
@pytest.fixture(scope='class')
def rxn_idx(self):
return 6
@pytest.fixture(scope='class')
def equation(self):
return "H2O(S) => H2O + PT(S)"
@pytest.fixture(scope='class')
def rate_type(self):
return "interface-Arrhenius"
@pytest.fixture(scope='class')
def orders(self):
return None
class SurfaceFullTests:
# Generic test class to check derivatives evaluated for an entire reaction
# mechanisms
rtol = 1e-4
@pytest.fixture(scope='class')
def all_species(self, gas, surf):
"""Combine species from both gas and surface phases."""
return surf.species() + gas.species()
@pytest.fixture(scope='class')
def sidxs(self, all_species):
"""Create a dictionary mapping species names to indices."""
return {spec.name: i for i, spec in enumerate(all_species)}
@pytest.fixture(scope='class')
def initial_conditions(self, gas, surf):
"""Store initial TPX conditions for gas and surface."""
return {
"gas_tpx": gas.TPX,
"surf_tpx": surf.TPX
}
@pytest.fixture(scope='function', autouse=True)
def setup_surface_full_data(self, gas, surf, initial_conditions):
"""Reset TPX and derivative settings before each test."""
# Initialize gas phase
gas.TPX = initial_conditions["gas_tpx"]
gas.derivative_settings = {}
# Initialize surface phase
surf.TPX = initial_conditions["surf_tpx"]
surf.derivative_settings = {
"skip-coverage-dependence": True,
"skip-electrochemistry": True
}
def get_concentrations(self, surf, gas):
"""Concatenate concentrations from surface and gas phases."""
return np.concatenate((surf.concentrations, gas.concentrations))
def test_forward_rop_ddCi(self, gas, surf):
# matrix multiplication of the forward rates of progress derivatives w.r.t
# concentration and the concentrations should provide the rate of progress
# for each species and can be compared to the directly calculated rate
drop = surf.forward_rates_of_progress_ddCi
rop = surf.forward_rates_of_progress
conc = self.get_concentrations(surf, gas)
# multiply derivatives with concentrations
drop = drop @ conc
# get total reactant reaction orders
total_orders = []
for rxn in surf.reactions():
orders = rxn.orders
curr_order = 0
for k, v in rxn.reactants.items():
if k in orders:
curr_order += orders[k]
else:
curr_order += v
total_orders.append(curr_order)
total_orders = np.array(total_orders)
# rates of progress do not factor in reaction order it must be accounted for
drop /= total_orders
# compare the rate of progress vectors produced in different ways
assert drop == approx(rop, rel=self.rtol)
def test_reverse_rop_ddCi(self, gas, surf):
# matrix multiplication of the reverse rate of progress derivatives w.r.t
# concentration and the concentrations should provide the rate of progress
# for each species and can be compared to the directly calculated rate
drop = surf.reverse_rates_of_progress_ddCi
rop = surf.reverse_rates_of_progress
conc = self.get_concentrations(surf, gas)
# multiply derivatives with concentrations
drop = drop @ conc
# get total reactant reaction orders
total_orders = []
for rxn in surf.reactions():
orders = rxn.orders
curr_order = 0
for k, v in rxn.products.items():
if k in orders:
curr_order += orders[k]
else:
curr_order += v
total_orders.append(curr_order)
total_orders = np.array(total_orders)
# rates of progress do not factor in reaction order it must be accounted for
drop /= total_orders
# compare the rate of progress vectors produced in different ways
assert drop == approx(rop, rel=self.rtol)
def test_net_rop_ddCi(self, gas, surf):
# check derivatives of net rates of progress with respect to species
# concentrations against analytic
ropf = surf.forward_rates_of_progress
ropr = surf.reverse_rates_of_progress
drop = surf.net_rates_of_progress_ddCi
conc = self.get_concentrations(surf, gas)
# multiply derivatives with concentrations
drop = drop @ conc
# reaction orders are not yet accounted for in rates of progress
# so they must be included manually
for i, rxn in enumerate(surf.reactions()):
orders = rxn.orders
curr_order = 0
# adjust forward rates by reactant order
for k, v in rxn.reactants.items():
curr_order += orders[k] if k in orders else v
ropf[i] *= curr_order
curr_order = 0
# adjust reverse rates by product order
for k, v in rxn.products.items():
curr_order += orders[k] if k in orders else v
ropr[i] *= curr_order
# compare the rate of progress vectors produced in different ways
assert drop == approx(ropf - ropr, rel=self.rtol)
class TestFullPlatinumHydrogen(SurfaceFullTests):
phase_defs = """
units: {length: cm, quantity: mol, activation-energy: J/mol}
phases:
- name: gas
thermo: ideal-gas
species:
- gri30.yaml/species: [H2, H2O, H2O2, O2]
kinetics: gas
reactions:
- gri30.yaml/reactions: declared-species
skip-undeclared-third-bodies: true
- name: Pt_surf
thermo: ideal-surface
species:
- ptcombust.yaml/species: [PT(S), H(S), H2O(S), OH(S), O(S)]
kinetics: surface
reactions: [ptcombust.yaml/reactions: declared-species]
site-density: 3e-09
"""
@pytest.fixture(scope='class')
def gas(self):
"""Fixture to create and configure the gas phase."""
gas = ct.Solution(yaml=self.phase_defs, name="gas")
gas.TPX = 800, 2 * ct.one_atm, "H2:1.5, O2:1.0, H2O2:0.75, H2O:0.3"
return gas
@pytest.fixture(scope='class')
def surf(self, gas):
"""Fixture to create and configure the surface phase."""
surf = ct.Interface(yaml=self.phase_defs, name="Pt_surf", adjacent=[gas])
surf.TPX = 800, 2 * ct.one_atm, "PT(S):1.0, H(S):0.5, H2O(S):0.1, OH(S):0.2, O(S):0.8"
return surf