pyreactlab-core 0.1.8__tar.gz → 0.1.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/PKG-INFO +1 -1
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyproject.toml +1 -1
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/configs/info.py +1 -1
- pyreactlab_core-0.1.9/pyreactlab_core/core/__init__.py +23 -0
- pyreactlab_core-0.1.9/pyreactlab_core/core/chem_react.py +420 -0
- pyreactlab_core-0.1.9/pyreactlab_core/core/chem_react_utils.py +149 -0
- pyreactlab_core-0.1.9/pyreactlab_core/core/reaction_component_mapper.py +206 -0
- pyreactlab_core-0.1.9/pyreactlab_core/core/reaction_network_analysis.py +291 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core.egg-info/PKG-INFO +1 -1
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core.egg-info/SOURCES.txt +3 -0
- pyreactlab_core-0.1.8/pyreactlab_core/core/__init__.py +0 -17
- pyreactlab_core-0.1.8/pyreactlab_core/core/chem_react.py +0 -1122
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/LICENSE +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/README.md +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/__init__.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/app.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/configs/__init__.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/configs/constants.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/docs/__init__.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/docs/chem_balance.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/docs/chem_utils.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/models/__init__.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/models/reaction.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/utils/__init__.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/utils/component_tools.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core/utils/tools.py +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core.egg-info/dependency_links.txt +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core.egg-info/requires.txt +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/pyreactlab_core.egg-info/top_level.txt +0 -0
- {pyreactlab_core-0.1.8 → pyreactlab_core-0.1.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyreactlab-core
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: pyreactlab-core is the core foundation of the PyReactLab ecosystem, offering shared data structures and algorithms for chemical reaction representation, stoichiometry, and reaction analysis.
|
|
5
5
|
Author-email: Sina Gilassi <sina.gilassi@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyreactlab-core"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.9"
|
|
8
8
|
description = "pyreactlab-core is the core foundation of the PyReactLab ecosystem, offering shared data structures and algorithms for chemical reaction representation, stoichiometry, and reaction analysis."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
# NOTE: chem react
|
|
3
|
+
from .chem_react import (
|
|
4
|
+
ReactionMode,
|
|
5
|
+
PhaseRule,
|
|
6
|
+
Reactant,
|
|
7
|
+
Product,
|
|
8
|
+
ChemReact
|
|
9
|
+
)
|
|
10
|
+
from .chem_react_utils import ChemReactUtils
|
|
11
|
+
from .reaction_component_mapper import ReactionComponentMapper
|
|
12
|
+
from .reaction_network_analysis import ReactionNetworkAnalysis
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ReactionMode",
|
|
16
|
+
"PhaseRule",
|
|
17
|
+
"Reactant",
|
|
18
|
+
"Product",
|
|
19
|
+
"ChemReact",
|
|
20
|
+
"ChemReactUtils",
|
|
21
|
+
"ReactionComponentMapper",
|
|
22
|
+
"ReactionNetworkAnalysis",
|
|
23
|
+
]
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# import libs
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, Any, List, Optional, Literal, TypedDict
|
|
5
|
+
from pythermodb_settings.models import Component, ComponentKey
|
|
6
|
+
# locals
|
|
7
|
+
from ..configs.constants import (
|
|
8
|
+
R_CONST_J__molK,
|
|
9
|
+
PRESSURE_REF_Pa,
|
|
10
|
+
TEMPERATURE_REF_K,
|
|
11
|
+
)
|
|
12
|
+
from .chem_react_utils import ChemReactUtils
|
|
13
|
+
from .reaction_component_mapper import ReactionComponentMapper
|
|
14
|
+
from .reaction_network_analysis import ReactionNetworkAnalysis
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# NOTE: logger
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# NOTE: Reaction Mode
|
|
21
|
+
ReactionMode = Literal["<=>", "=>", "="]
|
|
22
|
+
|
|
23
|
+
# NOTE: Phase Rule
|
|
24
|
+
PhaseRule = Literal["gas", "liquid", "aqueous", "solid"]
|
|
25
|
+
|
|
26
|
+
# SECTION: Models
|
|
27
|
+
# NOTE: reactants
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Reactant(TypedDict):
|
|
31
|
+
coefficient: float
|
|
32
|
+
molecule: str
|
|
33
|
+
state: str
|
|
34
|
+
molecule_state: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Product(TypedDict):
|
|
38
|
+
coefficient: float
|
|
39
|
+
molecule: str
|
|
40
|
+
state: str
|
|
41
|
+
molecule_state: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# SECTION: ChemReact class
|
|
45
|
+
class ChemReact(
|
|
46
|
+
ChemReactUtils,
|
|
47
|
+
ReactionComponentMapper,
|
|
48
|
+
ReactionNetworkAnalysis,
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Chemical Reaction Utilities
|
|
52
|
+
|
|
53
|
+
The ChemReact class provides utilities for analyzing and processing chemical reactions in various phases and conditions. These reactions can be represented in different ways depending on the dominant factors influencing them:
|
|
54
|
+
|
|
55
|
+
- Use = → when thermodynamics dominates
|
|
56
|
+
- Use <=> → when kinetics + thermodynamics matter
|
|
57
|
+
- Use => → when kinetics only matter
|
|
58
|
+
- The class supports reactions involving components in gas, liquid, aqueous, and solid phases.
|
|
59
|
+
- It includes methods for analyzing reactions, counting carbon atoms, determining reaction phases, and more.
|
|
60
|
+
"""
|
|
61
|
+
# # NOTE: variables
|
|
62
|
+
# system inputs
|
|
63
|
+
_system_inputs = None
|
|
64
|
+
# universal gas constant [J/mol.K]
|
|
65
|
+
R = R_CONST_J__molK
|
|
66
|
+
# temperature [K]
|
|
67
|
+
T_Ref = TEMPERATURE_REF_K
|
|
68
|
+
# pressure [bar]
|
|
69
|
+
P_Ref = PRESSURE_REF_Pa/1e5
|
|
70
|
+
|
|
71
|
+
# available phases
|
|
72
|
+
available_phases = PhaseRule.__args__
|
|
73
|
+
|
|
74
|
+
# NOTE: id separator
|
|
75
|
+
# ! used to separate component name and state
|
|
76
|
+
_id_separator: str = '-'
|
|
77
|
+
|
|
78
|
+
# NOTE: component checker
|
|
79
|
+
_component_checker: bool = False
|
|
80
|
+
|
|
81
|
+
# NOTE: stoichiometry source
|
|
82
|
+
_stoichiometry_source: dict[str, Any] = {}
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
reaction_mode_symbol: ReactionMode,
|
|
87
|
+
components: Optional[List[Component]],
|
|
88
|
+
component_keys: Optional[List[ComponentKey]] = None
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Initialize the ChemReactUtils class.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
reaction_mode_symbol : ReactionMode, optional
|
|
96
|
+
The symbol used to separate reactants and products in a reaction equation.
|
|
97
|
+
components : Optional[List[Component]]
|
|
98
|
+
A list of Component objects involved in the reaction.
|
|
99
|
+
component_keys : List[ComponentKey], optional
|
|
100
|
+
The key used to identify components in the reaction.
|
|
101
|
+
|
|
102
|
+
Notes
|
|
103
|
+
-----
|
|
104
|
+
- Use "<=>" when kinetics + thermodynamics matter
|
|
105
|
+
- Use "=" when thermodynamics dominates
|
|
106
|
+
- Use "=>" when kinetics only matter
|
|
107
|
+
- If components is None, component IDs will be an empty list.
|
|
108
|
+
- Component IDs are generated by combining the formula and state of each component, separated by a hyphen such as "H2O-l" for liquid water.
|
|
109
|
+
"""
|
|
110
|
+
# SECTION: parent class initialization
|
|
111
|
+
# NOTE: initialize general reaction utility settings
|
|
112
|
+
ChemReactUtils.__init__(
|
|
113
|
+
self,
|
|
114
|
+
available_phases=PhaseRule.__args__,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# NOTE: set reaction mode symbol used by this reaction parser
|
|
118
|
+
self.reaction_mode_symbol = reaction_mode_symbol
|
|
119
|
+
|
|
120
|
+
# NOTE: initialize component mapping settings
|
|
121
|
+
ReactionComponentMapper.__init__(
|
|
122
|
+
self,
|
|
123
|
+
components=components,
|
|
124
|
+
component_keys=component_keys,
|
|
125
|
+
id_separator=self._id_separator,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def system_inputs(self) -> Dict[str, Any]:
|
|
130
|
+
"""Get the system inputs."""
|
|
131
|
+
# check
|
|
132
|
+
if self._system_inputs is None:
|
|
133
|
+
raise ValueError("System inputs are not set.")
|
|
134
|
+
return self._system_inputs
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def stoichiometry_source(self) -> Dict[str, Any]:
|
|
138
|
+
"""Get the stoichiometry source."""
|
|
139
|
+
# res
|
|
140
|
+
return self._stoichiometry_source
|
|
141
|
+
|
|
142
|
+
def analyze_reaction(
|
|
143
|
+
self,
|
|
144
|
+
reaction_pack: Dict[str, str],
|
|
145
|
+
phase_rule: Optional[str] = None
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""
|
|
148
|
+
Analyze a chemical reaction and extract relevant information.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
reaction_pack : dict
|
|
153
|
+
A dictionary containing the reaction and its name.
|
|
154
|
+
phase_rule : str, optional
|
|
155
|
+
The phase of the reaction, which can be 'gas', 'liquid', 'aqueous', or 'solid'.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
dict
|
|
160
|
+
A dictionary containing the analyzed reaction data, including reactants,
|
|
161
|
+
products, reaction coefficient, and carbon count.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
# NOTE: check reaction_pack
|
|
165
|
+
if not isinstance(reaction_pack, dict):
|
|
166
|
+
raise ValueError("reaction_pack must be a dictionary.")
|
|
167
|
+
|
|
168
|
+
if 'reaction' not in reaction_pack or 'name' not in reaction_pack:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"reaction_pack must contain 'reaction' and 'name' keys.")
|
|
171
|
+
|
|
172
|
+
# NOTE: check phase
|
|
173
|
+
# set phase
|
|
174
|
+
phase_set = self.phase_rule_analysis(phase_rule)
|
|
175
|
+
|
|
176
|
+
# SECTION: extract data from reaction
|
|
177
|
+
reaction = reaction_pack['reaction']
|
|
178
|
+
name = reaction_pack['name']
|
|
179
|
+
|
|
180
|
+
# ! Split the reaction into left and right sides
|
|
181
|
+
sides = reaction.split(self.reaction_mode_symbol.strip())
|
|
182
|
+
|
|
183
|
+
# Define a regex pattern to match reactants/products
|
|
184
|
+
# pattern = r'(\d*)?(\w+)\((\w)\)'
|
|
185
|
+
# pattern = r'(\d*\.?\d+)?(\w+)\((\w)\)'
|
|
186
|
+
# pattern = r'(?:(\d*\.?\d+)\s*)?([A-Z][a-zA-Z0-9]*)\s*(?:\((\w)\))?'
|
|
187
|
+
# NOTE: multi-purpose pattern
|
|
188
|
+
pattern = r'(?:(\d*\.?\d+)\s*)?(e(?:\{-?1?\}|[+-])?|\[[^\]\s]+\](?:\d+)?(?:\{[^{}\s]+\})?|(?:(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*[A-Z][A-Za-z0-9]*(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*)(?:[·*](?:\d+)?(?:(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*[A-Z][A-Za-z0-9]*(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*))*(?:\{[^{}\s]+\})?)\s*(?:\((g|l|s|aq)\))?'
|
|
189
|
+
|
|
190
|
+
# SECTION: SECTION: Extract reactants and products
|
|
191
|
+
# Extract reactants
|
|
192
|
+
reactants_raw = re.findall(pattern, sides[0])
|
|
193
|
+
reactants: List[Reactant] = [
|
|
194
|
+
{
|
|
195
|
+
'coefficient': float(r[0]) if r[0] else float(1),
|
|
196
|
+
'molecule': r[1],
|
|
197
|
+
'state': r[2] if r[2] else phase_set,
|
|
198
|
+
'molecule_state': ''
|
|
199
|
+
} for r in reactants_raw
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
# NOTE: reactants full name
|
|
203
|
+
reactants_names = []
|
|
204
|
+
# loop over reactants
|
|
205
|
+
for i, item in enumerate(reactants):
|
|
206
|
+
# ! check phase_set and phase_rule
|
|
207
|
+
if phase_rule is None:
|
|
208
|
+
# check item state
|
|
209
|
+
if item['state'] == 'empty':
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"Phase rule is empty but reactant '{item['molecule']}' has state '{item['state']}'.")
|
|
212
|
+
else:
|
|
213
|
+
# check item state
|
|
214
|
+
if item['state'] != phase_set:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Phase rule is '{phase_set}' but reactant '{item['molecule']}' has state '{item['state']}'.")
|
|
217
|
+
|
|
218
|
+
# generate full name
|
|
219
|
+
full_name = item['molecule'] + "-" + item['state']
|
|
220
|
+
# append to list
|
|
221
|
+
reactants_names.append(full_name)
|
|
222
|
+
# update source
|
|
223
|
+
reactants[i]['molecule_state'] = full_name
|
|
224
|
+
|
|
225
|
+
# Extract products
|
|
226
|
+
products_raw = re.findall(pattern, sides[1])
|
|
227
|
+
products: List[Product] = [
|
|
228
|
+
{
|
|
229
|
+
'coefficient': float(p[0]) if p[0] else float(1),
|
|
230
|
+
'molecule': p[1],
|
|
231
|
+
'state': p[2] if p[2] else phase_set,
|
|
232
|
+
'molecule_state': ''
|
|
233
|
+
} for p in products_raw
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
# NOTE: products full name
|
|
237
|
+
products_names = []
|
|
238
|
+
# loop over products
|
|
239
|
+
for i, item in enumerate(products):
|
|
240
|
+
# ! check phase_set and phase_rule
|
|
241
|
+
if phase_rule is None:
|
|
242
|
+
# check item state
|
|
243
|
+
if item['state'] == 'empty':
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"Phase rule is empty but product '{item['molecule']}' has state '{item['state']}'.")
|
|
246
|
+
else:
|
|
247
|
+
# check item state
|
|
248
|
+
if item['state'] != phase_set:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"Phase rule is '{phase_set}' but product '{item['molecule']}' has state '{item['state']}'.")
|
|
251
|
+
|
|
252
|
+
# generate full name
|
|
253
|
+
full_name = item['molecule'] + "-" + item['state']
|
|
254
|
+
# append to list
|
|
255
|
+
products_names.append(full_name)
|
|
256
|
+
# update source
|
|
257
|
+
products[i]['molecule_state'] = full_name
|
|
258
|
+
|
|
259
|
+
# SECTION: all components
|
|
260
|
+
all_components = reactants_names + products_names
|
|
261
|
+
# >> remove duplicates
|
|
262
|
+
all_components: List[str] = list(set(all_components))
|
|
263
|
+
|
|
264
|
+
# SECTION: reaction coefficient and stoichiometry
|
|
265
|
+
reaction_coefficients = 0
|
|
266
|
+
reaction_stoichiometry = {}
|
|
267
|
+
reaction_stoichiometry_matrix = []
|
|
268
|
+
|
|
269
|
+
# iterate over reactants and products to calculate reaction coefficients
|
|
270
|
+
# NOTE: reactants
|
|
271
|
+
for item in reactants:
|
|
272
|
+
reaction_coefficients += item['coefficient']
|
|
273
|
+
reaction_stoichiometry[
|
|
274
|
+
item['molecule_state']
|
|
275
|
+
] = -1 * item['coefficient']
|
|
276
|
+
# append to stoichiometric matrix
|
|
277
|
+
reaction_stoichiometry_matrix.append(
|
|
278
|
+
-1 * item['coefficient']
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# NOTE: products
|
|
282
|
+
for item in products:
|
|
283
|
+
reaction_coefficients -= item['coefficient']
|
|
284
|
+
reaction_stoichiometry[
|
|
285
|
+
item['molecule_state']
|
|
286
|
+
] = item['coefficient']
|
|
287
|
+
# append to stoichiometric matrix
|
|
288
|
+
reaction_stoichiometry_matrix.append(
|
|
289
|
+
item['coefficient']
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# SECTION: Carbon count for each component
|
|
293
|
+
carbon_count = {}
|
|
294
|
+
for r in reactants:
|
|
295
|
+
carbon_count[r['molecule_state']] = self.count_carbon(
|
|
296
|
+
r['molecule'],
|
|
297
|
+
r['coefficient']
|
|
298
|
+
)
|
|
299
|
+
for p in products:
|
|
300
|
+
carbon_count[p['molecule_state']] = self.count_carbon(
|
|
301
|
+
p['molecule'],
|
|
302
|
+
p['coefficient']
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# SECTION: reaction state
|
|
306
|
+
reaction_state = {}
|
|
307
|
+
for r in reactants:
|
|
308
|
+
# set
|
|
309
|
+
reaction_state[r['molecule_state']] = r['state']
|
|
310
|
+
for p in products:
|
|
311
|
+
# set
|
|
312
|
+
reaction_state[p['molecule_state']] = p['state']
|
|
313
|
+
|
|
314
|
+
# NOTE: reaction phase
|
|
315
|
+
# reaction
|
|
316
|
+
reaction_phase = self.determine_reaction_phase(
|
|
317
|
+
reaction_state
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# NOTE: unique states
|
|
321
|
+
state_count = self.count_reaction_states(
|
|
322
|
+
reaction_state
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# SECTION: Symbolic reaction without states
|
|
326
|
+
symbolic_reaction = ""
|
|
327
|
+
symbolic_unbalanced_reaction = ""
|
|
328
|
+
|
|
329
|
+
# reactants
|
|
330
|
+
for i, r in enumerate(reactants):
|
|
331
|
+
if i == 0:
|
|
332
|
+
if r['coefficient'] == 1:
|
|
333
|
+
symbolic_reaction += f"{r['molecule']}"
|
|
334
|
+
else:
|
|
335
|
+
symbolic_reaction += f"{r['coefficient']}{r['molecule']}"
|
|
336
|
+
# unbalanced
|
|
337
|
+
symbolic_unbalanced_reaction += f"{r['molecule']}"
|
|
338
|
+
else:
|
|
339
|
+
if r['coefficient'] == 1:
|
|
340
|
+
symbolic_reaction += f" + {r['molecule']}"
|
|
341
|
+
else:
|
|
342
|
+
symbolic_reaction += f" + {r['coefficient']}{r['molecule']}"
|
|
343
|
+
# unbalanced
|
|
344
|
+
symbolic_unbalanced_reaction += f" + {r['molecule']}"
|
|
345
|
+
# reaction mode symbol
|
|
346
|
+
symbolic_reaction += f" {self.reaction_mode_symbol} "
|
|
347
|
+
symbolic_unbalanced_reaction += f" {self.reaction_mode_symbol} "
|
|
348
|
+
|
|
349
|
+
# products
|
|
350
|
+
for i, p in enumerate(products):
|
|
351
|
+
if i == 0:
|
|
352
|
+
if p['coefficient'] == 1:
|
|
353
|
+
symbolic_reaction += f"{p['molecule']}"
|
|
354
|
+
else:
|
|
355
|
+
symbolic_reaction += f"{p['coefficient']}{p['molecule']}"
|
|
356
|
+
# unbalanced
|
|
357
|
+
symbolic_unbalanced_reaction += f"{p['molecule']}"
|
|
358
|
+
else:
|
|
359
|
+
if p['coefficient'] == 1:
|
|
360
|
+
symbolic_reaction += f" + {p['molecule']}"
|
|
361
|
+
else:
|
|
362
|
+
symbolic_reaction += f" + {p['coefficient']}{p['molecule']}"
|
|
363
|
+
# unbalanced
|
|
364
|
+
symbolic_unbalanced_reaction += f" + {p['molecule']}"
|
|
365
|
+
|
|
366
|
+
# SECTION: set id for each component
|
|
367
|
+
# NOTE: component ids
|
|
368
|
+
component_ids = {}
|
|
369
|
+
for i, r in enumerate(reactants):
|
|
370
|
+
component_ids[r['molecule_state']] = i+1
|
|
371
|
+
offset = len(reactants)
|
|
372
|
+
for i, p in enumerate(products):
|
|
373
|
+
component_ids[p['molecule_state']] = offset + i + 1
|
|
374
|
+
|
|
375
|
+
# SECTION: collect components
|
|
376
|
+
components = self.collect_components(
|
|
377
|
+
reactants,
|
|
378
|
+
products
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# SECTION: map components
|
|
382
|
+
map_components = self.map_components(
|
|
383
|
+
reactants,
|
|
384
|
+
products,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# SECTION: build stoichiometry source
|
|
388
|
+
stoichiometry_source = self.build_stoichiometry_source(
|
|
389
|
+
reaction_stoichiometry=reaction_stoichiometry
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# res
|
|
393
|
+
res = {
|
|
394
|
+
'name': name,
|
|
395
|
+
'reaction': reaction,
|
|
396
|
+
"component_ids": component_ids,
|
|
397
|
+
"all_components": all_components,
|
|
398
|
+
"symbolic_reaction": symbolic_reaction,
|
|
399
|
+
"symbolic_unbalanced_reaction": symbolic_unbalanced_reaction,
|
|
400
|
+
'reactants': reactants,
|
|
401
|
+
'reactants_names': reactants_names,
|
|
402
|
+
'products': products,
|
|
403
|
+
'products_names': products_names,
|
|
404
|
+
'reaction_coefficients': reaction_coefficients,
|
|
405
|
+
'reaction_stoichiometry': reaction_stoichiometry,
|
|
406
|
+
'reaction_stoichiometry_matrix': reaction_stoichiometry_matrix,
|
|
407
|
+
'reaction_stoichiometry_source': stoichiometry_source,
|
|
408
|
+
'carbon_count': carbon_count,
|
|
409
|
+
'reaction_state': reaction_state,
|
|
410
|
+
'reaction_phase': reaction_phase,
|
|
411
|
+
'state_count': state_count,
|
|
412
|
+
'components': components,
|
|
413
|
+
'map_components': map_components,
|
|
414
|
+
'component_checker': self._component_checker,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return res
|
|
418
|
+
except Exception as e:
|
|
419
|
+
raise Exception(f"Error analyzing reaction: {e}")
|
|
420
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# import libs
|
|
2
|
+
import re
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# SECTION: ChemReactUtils class
|
|
7
|
+
class ChemReactUtils:
|
|
8
|
+
"""General-purpose helpers for chemical reaction analysis."""
|
|
9
|
+
|
|
10
|
+
# NOTE: supported full phase names
|
|
11
|
+
available_phases = ("gas", "liquid", "aqueous", "solid")
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
available_phases: tuple[str, ...] | None = None,
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
Initialize general chemical reaction utility settings.
|
|
19
|
+
"""
|
|
20
|
+
# SECTION: phase configuration
|
|
21
|
+
# NOTE: child classes can override the supported phase names
|
|
22
|
+
if available_phases is not None:
|
|
23
|
+
self.available_phases = available_phases
|
|
24
|
+
|
|
25
|
+
def count_carbon(self, molecule: str, coefficient: float) -> float:
|
|
26
|
+
"""
|
|
27
|
+
Count the number of carbon atoms in a molecule.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
# SECTION: validate inputs
|
|
31
|
+
# NOTE: molecule formula must be text for regex parsing
|
|
32
|
+
if not isinstance(molecule, str):
|
|
33
|
+
raise ValueError("Molecule must be a string.")
|
|
34
|
+
|
|
35
|
+
# NOTE: coefficient scales the carbon count
|
|
36
|
+
if not isinstance(coefficient, (int, float)):
|
|
37
|
+
raise ValueError("Coefficient must be an integer or float.")
|
|
38
|
+
|
|
39
|
+
# SECTION: carbon symbol matching
|
|
40
|
+
# ! do not count lowercase carbon inside another element symbol
|
|
41
|
+
if re.search(r'C(?![a-z])', molecule):
|
|
42
|
+
# NOTE: multiply atom occurrences by stoichiometric coefficient
|
|
43
|
+
carbon_count = len(re.findall(
|
|
44
|
+
r'C(?![a-z])', molecule)) * coefficient
|
|
45
|
+
return carbon_count
|
|
46
|
+
else:
|
|
47
|
+
# NOTE: molecule has no carbon atoms
|
|
48
|
+
return 0.0
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise Exception(
|
|
51
|
+
f"Error counting carbon in molecule '{molecule}': {e}")
|
|
52
|
+
|
|
53
|
+
def phase_rule_analysis(self, phase_rule: Optional[str] = None) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Analyze the phase rule of a reaction.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
# SECTION: default phase rule
|
|
59
|
+
# NOTE: empty means component states must be present in the reaction
|
|
60
|
+
if phase_rule is None or phase_rule == 'None':
|
|
61
|
+
return 'empty'
|
|
62
|
+
|
|
63
|
+
# SECTION: validate phase rule
|
|
64
|
+
# ? keep this aligned with PhaseRule in chem_react.py
|
|
65
|
+
if phase_rule not in self.available_phases:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Phase rule must be {', '.join(self.available_phases)}.")
|
|
68
|
+
|
|
69
|
+
# SECTION: convert full phase name to reaction state symbol
|
|
70
|
+
if phase_rule == 'gas':
|
|
71
|
+
phase_symbol = 'g'
|
|
72
|
+
elif phase_rule == 'liquid':
|
|
73
|
+
phase_symbol = 'l'
|
|
74
|
+
elif phase_rule == 'aqueous':
|
|
75
|
+
phase_symbol = 'aq'
|
|
76
|
+
elif phase_rule == 'solid':
|
|
77
|
+
phase_symbol = 's'
|
|
78
|
+
else:
|
|
79
|
+
phase_symbol = 'empty'
|
|
80
|
+
|
|
81
|
+
# NOTE: return compact state symbol used by parsed components
|
|
82
|
+
return phase_symbol
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise Exception(f"Error analyzing phase rule: {e}")
|
|
85
|
+
|
|
86
|
+
def state_name_set(self, state_set: set) -> List[str]:
|
|
87
|
+
"""
|
|
88
|
+
Convert state set to full names.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
# SECTION: state name mapping
|
|
92
|
+
# NOTE: keys match state symbols parsed from reaction strings
|
|
93
|
+
state_dict = {
|
|
94
|
+
'g': 'gas',
|
|
95
|
+
'l': 'liquid',
|
|
96
|
+
'aq': 'aqueous',
|
|
97
|
+
's': 'solid'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# NOTE: convert each compact symbol to its full phase name
|
|
101
|
+
return [state_dict[state] for state in state_set]
|
|
102
|
+
except Exception as e:
|
|
103
|
+
raise Exception(f"Error converting state set to full names: {e}")
|
|
104
|
+
|
|
105
|
+
def determine_reaction_phase(self, reaction_dict: Dict[str, str]) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Determine the phase of a reaction based on component states.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
# SECTION: collect unique states
|
|
111
|
+
available_states = set(reaction_dict.values())
|
|
112
|
+
# NOTE: convert state symbols before formatting phase text
|
|
113
|
+
state_names = self.state_name_set(available_states)
|
|
114
|
+
|
|
115
|
+
# SECTION: determine reaction phase label
|
|
116
|
+
if len(state_names) == 1:
|
|
117
|
+
# NOTE: single-phase reaction
|
|
118
|
+
return f'{state_names[0]}'
|
|
119
|
+
else:
|
|
120
|
+
# NOTE: multi-phase reaction
|
|
121
|
+
return f'{"-".join(state_names)}'
|
|
122
|
+
except Exception as e:
|
|
123
|
+
raise Exception(f"Error determining reaction phase: {e}")
|
|
124
|
+
|
|
125
|
+
def count_reaction_states(self, reaction_dict: Dict[str, str]) -> Dict[str, int]:
|
|
126
|
+
"""
|
|
127
|
+
Count the number of component states in a reaction.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
# SECTION: collect component states
|
|
131
|
+
available_states = reaction_dict.values()
|
|
132
|
+
# NOTE: initialize all supported state buckets
|
|
133
|
+
state_count = {
|
|
134
|
+
'g': 0,
|
|
135
|
+
'l': 0,
|
|
136
|
+
'aq': 0,
|
|
137
|
+
's': 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# SECTION: count state occurrences
|
|
141
|
+
for state in available_states:
|
|
142
|
+
# ! ignore unsupported states instead of adding new keys
|
|
143
|
+
if state in state_count:
|
|
144
|
+
state_count[state] += 1
|
|
145
|
+
|
|
146
|
+
# NOTE: return counts for every supported state symbol
|
|
147
|
+
return state_count
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise Exception(f"Error determining reaction phase: {e}")
|