diff --git a/arkane/isodesmic.py b/arkane/isodesmic.py index f45872bcb9..763ebdb7d4 100644 --- a/arkane/isodesmic.py +++ b/arkane/isodesmic.py @@ -44,7 +44,11 @@ from __future__ import division +import signal + +from lpsolve55 import lpsolve, EQ, LE import numpy as np +import pyomo.environ as pyo from rmgpy.molecule import Molecule from rmgpy.quantity import ScalarQuantity @@ -265,6 +269,112 @@ def __init__(self, target, reference_set, conserve_bonds, conserve_ring_size): self.target_constraint, self.constraint_matrix = self.constraints.calculate_constraints() self.reference_species = self.constraints.reference_species + def _find_error_canceling_reaction(self, reference_subset, milp_software='lpsolve'): + """ + Automatically find a valid error canceling reaction given a subset of the available benchmark species. This + is done by solving a mixed integer linear programming (MILP) problem similiar to + Buerger et al. (https://doi.org/10.1016/j.combustflame.2017.08.013) + + Args: + reference_subset (list): A list of indices from self.reference_species that can participate in the reaction + milp_software (str, optional): 'lpsolve' (default) or 'pyomo'. lpsolve is usually faster. + + Returns: + ErrorCancelingReaction: reaction with the target species (if a valid reaction is found, else `None`) + """ + # Define the constraints based on the provided subset + c_matrix = np.take(self.constraint_matrix, reference_subset, axis=0) + c_matrix = np.tile(c_matrix, (2, 1)) + sum_constraints = np.sum(c_matrix, 1, dtype=int) + targets = -1*self.target_constraint + m = c_matrix.shape[0] + n = c_matrix.shape[1] + split = int(m/2) + solution = None + + if milp_software == 'pyomo': + # Setup the MILP problem using pyomo + lp_model = pyo.ConcreteModel() + lp_model.i = pyo.RangeSet(0, m - 1) + lp_model.j = pyo.RangeSet(0, n - 1) + lp_model.r = pyo.RangeSet(0, split-1) # indices before the split correspond to reactants + lp_model.p = pyo.RangeSet(split, m - 1) # indices after the split correspond to products + lp_model.v = pyo.Var(lp_model.i, domain=pyo.NonNegativeIntegers) # The stoich. coef. we are solving for + lp_model.c = pyo.Param(lp_model.i, lp_model.j, initialize=lambda _, i, j: c_matrix[i, j]) + lp_model.s = pyo.Param(lp_model.i, initialize=lambda _, i: sum_constraints[i]) + lp_model.t = pyo.Param(lp_model.j, initialize=lambda _, j: targets[j]) + + def obj_expression(model): + return pyo.summation(model.v, model.s, index=model.i) + + lp_model.obj = pyo.Objective(rule=obj_expression) + + def constraint_rule(model, j): + return sum(model.v[i] * model.c[i, j] for i in model.r) - \ + sum(model.v[i] * model.c[i, j] for i in model.p) == model.t[j] + + lp_model.constraints = pyo.Constraint(lp_model.j, rule=constraint_rule) + + # Solve the MILP problem using the CBC MILP solver (https://www.coin-or.org/Cbc/) + opt = pyo.SolverFactory('glpk') + results = opt.solve(lp_model) + + # Return None if a valid reaction is not found + if results.solver.status != pyo.SolverStatus.ok: + return None + + # Extract the solution and find the species with non-zero stoichiometric coefficients + solution = lp_model.v.extract_values().values() + + elif milp_software == 'lpsolve': + # Save the current signal handler + sig = signal.getsignal(signal.SIGINT) + + # Setup the MILP problem using lpsolve + lp = lpsolve('make_lp', 0, m) + lpsolve('set_verbose', lp, 2) # Reduce the logging from lpsolve + lpsolve('set_obj_fn', lp, sum_constraints) + lpsolve('set_minim', lp) + + for j in range(n): + lpsolve('add_constraint', lp, np.concatenate((c_matrix[:split, j], -1*c_matrix[split:, j])), EQ, + targets[j]) + + lpsolve('add_constraint', lp, np.ones(m), LE, 20) # Use at most 20 species (including replicates) + lpsolve('set_timeout', lp, 5) # Move on if lpsolve can't find a solution quickly + + # Constrain v_i to be 4 or less + for i in range(m): + lpsolve('set_upbo', lp, i, 4) + + # All v_i must be integers + lpsolve('set_int', lp, [True]*m) + + status = lpsolve('solve', lp) + + # Reset signal handling since lpsolve changed it + try: + signal.signal(signal.SIGINT, sig) + except ValueError: + # This is not being run in the main thread, so we cannot reset signal + pass + + if status != 0: + return None + + else: + _, solution = lpsolve('get_solution', lp)[:2] + + reaction = ErrorCancelingReaction(self.target, dict()) + for index, v in enumerate(solution): + if v > 0: + if index < split: + reaction.species.update({self.reference_species[reference_subset[index]]: -v}) + else: + reaction.species.update({self.reference_species[reference_subset[index % split]]: v}) + + return reaction + class IsodesmicScheme(ErrorCancelingScheme): """ diff --git a/arkane/isodesmicTest.py b/arkane/isodesmicTest.py index afbcfa374d..29949ab62c 100644 --- a/arkane/isodesmicTest.py +++ b/arkane/isodesmicTest.py @@ -185,7 +185,9 @@ class TestErrorCancelingScheme(unittest.TestCase): def setUp(self): self.propene = ErrorCancelingSpecies(Molecule(SMILES='CC=C'), (100, 'kJ/mol'), 'test') + self.propane = ErrorCancelingSpecies(Molecule(SMILES='CCC'), (75, 'kJ/mol'), 'test') self.butane = ErrorCancelingSpecies(Molecule(SMILES='CCCC'), (150, 'kJ/mol'), 'test') + self.butene = ErrorCancelingSpecies(Molecule(SMILES='C=CCC'), (175, 'kJ/mol'), 'test') self.benzene = ErrorCancelingSpecies(Molecule(SMILES='c1ccccc1'), (-50, 'kJ/mol'), 'test') self.caffeine = ErrorCancelingSpecies(Molecule(SMILES='CN1C=NC2=C1C(=O)N(C(=O)N2C)C'), (300, 'kJ/mol'), 'test') self.ethyne = ErrorCancelingSpecies(Molecule(SMILES='C#C'), (200, 'kJ/mol'), 'test') @@ -199,6 +201,23 @@ def test_creating_error_canceling_schemes(self): self.assertEqual(isodesmic_scheme.reference_species, [self.butane, self.benzene]) + def test_find_error_canceling_reaction(self): + """ + Test that the MILP problem can be solved to find a single isodesmic reaction + """ + scheme = IsodesmicScheme(self.propene, [self.propane, self.butane, self.butene, self.caffeine, self.ethyne]) + + # Note that caffeine and ethyne will not be allowed, so for the full set the indices are [0, 1, 2] + rxn = scheme._find_error_canceling_reaction([0, 1, 2], milp_software='lpsolve') + self.assertEqual(rxn.species[self.butane], -1) + self.assertEqual(rxn.species[self.propane], 1) + self.assertEqual(rxn.species[self.butene], 1) + + rxn = scheme._find_error_canceling_reaction([0, 1, 2], milp_software='pyomo') + self.assertEqual(rxn.species[self.butane], -1) + self.assertEqual(rxn.species[self.propane], 1) + self.assertEqual(rxn.species[self.butene], 1) + if __name__ == '__main__': unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) diff --git a/environment_linux.yml b/environment_linux.yml index d499d6fa0b..15f2a9b3e3 100644 --- a/environment_linux.yml +++ b/environment_linux.yml @@ -10,11 +10,13 @@ dependencies: - cairo - cairocffi - cantera >=2.3.0 + - coincbc - coolprop - coverage - cython >=0.25.2 - dde - ffmpeg + - glpk - gprof2dot - graphviz - jinja2 @@ -37,6 +39,7 @@ dependencies: - pydqed >=1.0.0 - pygpu - pymongo + - pyomo - pyparsing - pyrdl - python >=2.7 diff --git a/environment_mac.yml b/environment_mac.yml index 86dd9c4faa..00060f05a2 100644 --- a/environment_mac.yml +++ b/environment_mac.yml @@ -10,11 +10,13 @@ dependencies: - cairo - cairocffi - cantera >=2.3.0 + - coincbc - coolprop - coverage - cython >=0.25.2 - dde - ffmpeg + - glpk - gprof2dot - graphviz - jinja2 @@ -37,6 +39,7 @@ dependencies: - pydqed >=1.0.0 - pygpu - pymongo + - pyomo - pyparsing - pyrdl - python >=2.7 diff --git a/environment_windows.yml b/environment_windows.yml index 507ead32c8..6fe391bbe8 100644 --- a/environment_windows.yml +++ b/environment_windows.yml @@ -10,11 +10,13 @@ dependencies: - cairo - cairocffi - cantera >=2.3.0 + - coincbc - coolprop - coverage - cython >=0.25.2 - dde - ffmpeg + - glpk - gprof2dot - graphviz - jinja2 @@ -38,6 +40,7 @@ dependencies: - pydqed >=1.0.0 - pygpu - pymongo + - pyomo - pyparsing - pyrdl - python >=2.7 diff --git a/requirements.txt b/requirements.txt index aff4494247..b7667d1b8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,8 @@ MarkupSafe # this makes Jinja2 faster Jinja2 # this is for rendering the HTML output files cairocffi yaml +pyomo # For MILP problems such as Clar structure generation and automatic generation of error canceling reactions +coincbc # MILP solver # For postprocessing the profiling data argparse