"""This module for defining chemical reaction objects was originally sourced frompymatgen and streamlined for the reaction-network code."""from__future__importannotationsimportrefromcopyimportdeepcopyfromfunctoolsimportcached_propertyfromitertoolsimportchain,combinationsfromtypingimportTYPE_CHECKINGimportnumpyasnpfrommonty.fractionsimportgcd_floatfromrxn_network.coreimportCompositionfromrxn_network.dataimportCOMMON_GASESfromrxn_network.reactions.baseimportReactionifTYPE_CHECKING:fromcollections.abcimportIterablefrompymatgen.core.periodic_tableimportElementTOLERANCE=1e-6# Tolerance for determining if a particular component fraction is > 0.
[docs]classBasicReaction(Reaction):"""An object representing a basic chemical reaction: compositions and their coefficients. """def__init__(self,compositions:Iterable[Composition],coefficients:Iterable[float]|np.ndarray,balanced:bool|None=None,data:dict|None=None,lowest_num_errors:float=0,):"""A BasicReaction object is defined by a list of compositions and their corresponding coefficients, where a negative coefficient refers to a reactant, and a positive coefficient refers to a product. Args: compositions: List of composition objects (pymatgen). coefficients: List of coefficients, where negative coeff distinguishes a reactant. balanced: Whether the reaction is stoichiometricaly balanced or not (see construction via balance() method). data: Optional corresponding data in dictionary format; often used to store various calculated parameters. lowest_num_errors: the minimum number of errors reported by the reaction balancing algorithm (see the balance() method). A number of errors >= 1 means that the reaction may be different than intended (some phases may be shuffled or removed entirely). """self._compositions=[Composition(c)forcincompositions]self._coefficients=np.array(coefficients)self.reactant_coeffs={comp:coeffforcomp,coeffinzip(self._compositions,self._coefficients)ifcoeff<0}self.product_coeffs={comp:coeffforcomp,coeffinzip(self._compositions,self._coefficients)ifcoeff>0}ifbalancedisnotNone:self.balanced=balancedelse:sum_reactants=sum([k*abs(v)fork,vinself.reactant_coeffs.items()],Composition({}))sum_products=sum([k*abs(v)fork,vinself.product_coeffs.items()],Composition({}))ifnotsum_reactants.almost_equals(sum_products,rtol=0,atol=TOLERANCE):self.balanced=Falseelse:self.balanced=Trueself.data=dataifdataelse{}self.lowest_num_errors=lowest_num_errors
[docs]@classmethoddefbalance(cls,reactants:list[Composition],products:list[Composition],data:dict|None=None,)->BasicReaction:"""Reactants and products to be specified as list of pymatgen.core.Composition. e.g., [comp1, comp2]. Args: reactants: List of reactants. products: List of products. data: Optional dictionary containing extra data about the reaction. """compositions=list(reactants+products)coeffs,lowest_num_errors,num_constraints=cls._balance_coeffs(reactants,products)ifnotdata:data={}data["num_constraints"]=num_constraintsbalanced=TrueifcoeffsisNoneorlowest_num_errors==np.inf:balanced=Falsecoeffs=np.zeros(len(compositions))returncls(compositions=compositions,coefficients=coeffs,balanced=balanced,data=data,lowest_num_errors=lowest_num_errors,)
[docs]defnormalize_to(self,comp:Composition,factor:float=1)->BasicReaction:"""Normalizes the reaction to one of the compositions via the provided factor. By default, normalizes such that the composition given has a coefficient of 1. Args: comp: Composition object to normalize to factor: factor to normalize to. Defaults to 1. """all_comp=self.compositionscoeffs=self.coefficients.copy()scale_factor=abs(1/coeffs[self.compositions.index(comp)]*factor)coeffs*=scale_factorreturnBasicReaction(all_comp,coeffs)
[docs]defnormalize_to_element(self,element:Element,factor:float=1)->BasicReaction:"""Normalizes the reaction to one of the elements. By default, normalizes such that the amount of the element is 1. Another factor can be specified. Args: element (Element/Species): Element to normalize to. factor (float): Factor to normalize to. Defaults to 1. """all_comp=self.compositionscoeffs=self.coefficients.copy()current_el_amount=sum(all_comp[i][element]*abs(coeffs[i])foriinrange(len(all_comp)))/2scale_factor=factor/current_el_amountcoeffs*=scale_factorreturnBasicReaction(all_comp,coeffs)
[docs]defget_el_amount(self,element:Element)->float:"""Returns the amount of the element in the reaction. Args: element (Element/Species): Element in the reaction Returns: Amount of that element in the reaction. """returnsum(self.compositions[i][element]*abs(self.coefficients[i])foriinrange(len(self.compositions)))/2
[docs]defget_coeff(self,comp:Composition):"""Returns coefficient for a particular composition."""returnself.coefficients[self.compositions.index(comp)]
[docs]defnormalized_repr_and_factor(self):"""Normalized representation for a reaction For example, ``4 Li + 2 O -> 2Li2O`` becomes ``2 Li + O -> Li2O``. """returnself._str_from_comp(self.coefficients,self.compositions,reduce=True)
[docs]defcopy(self)->BasicReaction:"""Returns a copy of the BasicReaction object."""returnBasicReaction(compositions=self.compositions,coefficients=self.coefficients.copy(),balanced=self.balanced,data=self.data,lowest_num_errors=self.lowest_num_errors,)
[docs]defreverse(self)->BasicReaction:"""Returns a copy of the original BasicReaction object where original reactants are new products, and vice versa. """returnBasicReaction(compositions=self.compositions,coefficients=-1*self.coefficients.copy(),balanced=self.balanced,data=self.data,lowest_num_errors=self.lowest_num_errors,)
[docs]defis_separable(self,target:str|Composition)->bool:"""Checks if the reaction forms byproducts which are separable from the target composition. Separable byproducts are those that are common gases (e.g., CO2), or other phases that do not contain any of the elements in the target phase. Args: target: Composition of target; elements in this phase will be used to determine whether byproducts only contain added elements. Returns: True if reaction is separable from target, False otherwise. """target=Composition(target)identified_targets=[cforcinself.compositionsifc.reduced_composition==target.reduced_composition]iflen(identified_targets)==0:raiseValueError(f"Target composition {target} not in reaction {self}")added_elems=set(self.elements)-set(target.elements)products=set(deepcopy(self.products))fortinidentified_targets:products.remove(t)are_separable=[]forcompinproducts:ifcompinCOMMON_GASESoradded_elems.issuperset(comp.elements):are_separable.append(True)else:are_separable.append(False)returnall(are_separable)
@cached_propertydefreactant_atomic_fractions(self)->dict:"""Returns the atomic mixing ratio of reactants in the reaction."""ifnotself.balanced:raiseValueError("Reaction is not balanced")return{c.reduced_composition:-coeff*c.num_atoms/self.num_atomsforc,coeffinself.reactant_coeffs.items()}@cached_propertydefproduct_atomic_fractions(self)->dict:"""Returns the atomic mixing ratio of reactants in the reaction."""ifnotself.balanced:raiseValueError("Reaction is not balanced")return{c.reduced_composition:coeff*c.num_atoms/self.num_atomsforc,coeffinself.product_coeffs.items()}@cached_propertydefreactant_molar_fractions(self)->dict:"""Returns the molar mixing ratio of reactants in the reaction."""ifnotself.balanced:raiseValueError("Reaction is not balanced")total=sum(self.reactant_coeffs.values())return{c:coeff/totalforc,coeffinself.reactant_coeffs.items()}@cached_propertydefproduct_molar_fractions(self)->dict:"""Returns the molar mixing ratio of products in the reaction."""ifnotself.balanced:raiseValueError("Reaction is not balanced")total=sum(self.product_coeffs.values())return{c:coeff/totalforc,coeffinself.product_coeffs.items()}
[docs]@classmethoddeffrom_string(cls,rxn_string:str)->BasicReaction:"""Generates a balanced reaction from a string. The reaction must already be balanced. Args: rxn_string: The reaction string. For example, "4 Li + O2-> 2 Li2O" Returns: BalancedReaction """rct_str,prod_str=rxn_string.split("->")defget_comp_amt(comp_str):return{Composition(m.group(2)):float(m.group(1)or1)forminre.finditer(r"([\d\.]*(?:[eE]-?[\d\.]+)?)\s*([A-Z][\w\.\(\)]*)",comp_str)}reactant_coeffs=get_comp_amt(rct_str)product_coeffs=get_comp_amt(prod_str)returncls._from_coeff_dicts(reactant_coeffs,product_coeffs)
[docs]@classmethoddeffrom_formulas(cls,reactants:list[str],products:list[str])->BasicReaction:"""Initialize a reaction from a list of 1) reactant formulas and 2) product formulas. Args: reactants: List of reactant formulas products: List of product formulas Returns: A BasicReaction object """reactant_comps=[Composition(r)forrinreactants]product_comps=[Composition(p)forpinproducts]returncls.balance(reactants=reactant_comps,products=product_comps)
@propertydefreactants(self)->list[Composition]:"""List of reactants for this reaction."""returnlist(self.reactant_coeffs.keys())@propertydefproducts(self)->list[Composition]:"""List of products for this reaction."""returnlist(self.product_coeffs.keys())@propertydefcompositions(self)->list[Composition]:"""List of composition objects for this reaction."""returnself._compositions@propertydefcoefficients(self)->np.ndarray:"""Array of reaction coefficients."""returnself._coefficients@cached_propertydefnum_atoms(self)->float:"""Total number of atoms in this reaction."""returnsum(coeff*sum(c[el]forelinself.elements)forc,coeffinself.product_coeffs.items())@cached_propertydefenergy(self)->float:"""The energy of this reaction."""raiseValueError("No energy for a basic reaction!")@cached_propertydefenergy_per_atom(self)->float:"""The energy per atom of this reaction."""raiseValueError("No energy per atom for a basic reaction!")@cached_propertydefis_identity(self)->bool:"""Returns True if the reaction has identical reactants and products."""returnself._get_is_identity()def_get_is_identity(self):"""Returns True if the reaction has identical reactants and products."""ifset(self.reactants)!=set(self.products):returnFalseifself.balancedisFalse:# if not balanced, can not check coefficientsreturnTruereturnall(np.isclose(self.reactant_coeffs[c]*-1,self.product_coeffs[c])forcinself.reactant_coeffs)@cached_propertydefchemical_system(self)->str:"""Returns the chemical system as string in the form of A-B-C-..."""return"-".join(sorted([str(el)forelinself.elements]))@propertydefnormalized_repr(self)->str:"""A normalized representation of the reaction. All factors are converted to lowest common factors. """returnself.normalized_repr_and_factor()[0]@classmethoddef_balance_coeffs(cls,reactants:list[Composition],products:list[Composition])->tuple[np.ndarray,int|float,int]:"""Balances the reaction and returns the new coefficient matrix."""compositions=reactants+productsnum_comp=len(compositions)all_elems=sorted({elemforcincompositionsforeleminc.elements})num_elems=len(all_elems)comp_matrix=np.array([[c[el]forelinall_elems]forcincompositions]).Trank=np.linalg.matrix_rank(comp_matrix)diff=num_comp-ranknum_constraints=diffifdiff>=2else1# an error = a component changing sides or disappearinglowest_num_errors=np.inffirst_product_idx=len(reactants)# start with simplest product constraints, work to more complex constraintsproduct_constraints=chain.from_iterable([combinations(range(first_product_idx,num_comp),n_constr)forn_constrinrange(num_constraints,0,-1)])reactant_constraints=chain.from_iterable([combinations(range(first_product_idx),n_constr)forn_constrinrange(num_constraints,0,-1)])best_soln=np.zeros(num_comp)forconstraintsinchain(product_constraints,reactant_constraints):n_constr=len(constraints)comp_and_constraints=np.append(comp_matrix,np.zeros((n_constr,num_comp)),axis=0)b=np.zeros((num_elems+n_constr,1))b[-n_constr:]=1ifmin(constraints)>=first_product_idxelse-1fornum,idxinenumerate(constraints):comp_and_constraints[num_elems+num,idx]=1# arbitrarily fix coeff to 1coeffs=np.matmul(np.linalg.pinv(comp_and_constraints),b)num_errors=0ifnp.allclose(np.matmul(comp_matrix,coeffs),np.zeros((num_elems,1))):expected_signs=np.array([-1]*len(reactants)+[+1]*len(products))num_errors=np.sum(np.multiply(expected_signs,coeffs.T)<TOLERANCE)ifnum_errors==0:lowest_num_errors=0best_soln=coeffsbreakifnum_errors<lowest_num_errors:lowest_num_errors=num_errorsbest_soln=coeffsreturnnp.squeeze(best_soln),lowest_num_errors,num_constraints@staticmethoddef_from_coeff_dicts(reactant_coeffs,product_coeffs)->BasicReaction:reactant_comps,r_coefs=zip(*[(comp,-1*coeff)forcomp,coeffinreactant_coeffs.items()])product_comps,p_coefs=zip(*list(product_coeffs.items()))returnBasicReaction(reactant_comps+product_comps,r_coefs+p_coefs)@staticmethoddef_str_from_formulas(coeffs,formulas,tol=TOLERANCE)->str:reactant_str=[]product_str=[]foramt,formulainzip(coeffs,formulas):ifabs(amt+1)<tol:reactant_str.append(formula)elifabs(amt-1)<tol:product_str.append(formula)elifamt<-tol:reactant_str.append(f"{-amt:.4g}{formula}")elifamt>tol:product_str.append(f"{amt:.4g}{formula}")return" + ".join(reactant_str)+" -> "+" + ".join(product_str)@classmethoddef_str_from_comp(cls,coeffs,compositions,reduce=False)->tuple[str,float]:r_coeffs=np.zeros(len(coeffs))r_formulas=[]fori,(amt,comp)inenumerate(zip(coeffs,compositions)):formula,factor=comp.get_reduced_formula_and_factor()r_coeffs[i]=amt*factorr_formulas.append(formula)ifreduce:factor=1/gcd_float(np.abs(r_coeffs))r_coeffs*=factorelse:factor=1returncls._str_from_formulas(r_coeffs,r_formulas),factordef__eq__(self,other)->bool:ifselfisother:returnTrueifnotself.chemical_system==other.chemical_system:returnFalseifnotlen(self.products)==len(other.products):returnFalseifnotlen(self.reactants)==len(other.reactants):returnFalseifnotnp.allclose(sorted(self.coefficients),sorted(other.coefficients)):returnFalseifnotset(self.reactants)==set(other.reactants):returnFalseifnotset(self.products)==set(other.products):returnFalsereturnTruedef__hash__(self):returnhash((self.chemical_system,tuple(sorted(self.coefficients))))# not checking here for reactions that are multiples (too expensive)def__str__(self)->str:returnself._str_from_comp(self.coefficients,self.compositions)[0]def__repr__(self)->str:returnself.__str__()