pyreactlab-core 0.1.9__tar.gz → 0.3.0__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.9 → pyreactlab_core-0.3.0}/PKG-INFO +1 -1
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyproject.toml +1 -1
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/__init__.py +11 -1
- pyreactlab_core-0.3.0/pyreactlab_core/configs/constants.py +95 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/info.py +1 -1
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/chem_react.py +67 -29
- pyreactlab_core-0.3.0/pyreactlab_core/core/chem_react_utils.py +344 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/models/reaction.py +60 -15
- pyreactlab_core-0.3.0/pyreactlab_core/models/reactions.py +30 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/component_tools.py +1 -2
- pyreactlab_core-0.3.0/pyreactlab_core/utils/reaction_tools.py +150 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/tools.py +1 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/PKG-INFO +1 -1
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/SOURCES.txt +2 -0
- pyreactlab_core-0.1.9/pyreactlab_core/configs/constants.py +0 -40
- pyreactlab_core-0.1.9/pyreactlab_core/core/chem_react_utils.py +0 -149
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/LICENSE +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/README.md +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/app.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/__init__.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/__init__.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_component_mapper.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_network_analysis.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/__init__.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/chem_balance.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/chem_utils.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/models/__init__.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/__init__.py +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/dependency_links.txt +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/requires.txt +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/top_level.txt +0 -0
- {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyreactlab-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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.
|
|
7
|
+
version = "0.3.0"
|
|
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"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# config
|
|
1
|
+
# NOTE: config
|
|
2
2
|
from .configs.info import (
|
|
3
3
|
__version__,
|
|
4
4
|
__author__,
|
|
@@ -8,6 +8,13 @@ from .configs.info import (
|
|
|
8
8
|
__license__,
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
# NOTE: configs
|
|
12
|
+
from .configs.constants import (
|
|
13
|
+
REACTION_SYMBOLIC_MODES,
|
|
14
|
+
ReactionMode
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# NOTE: app
|
|
11
18
|
from .app import (
|
|
12
19
|
rxn,
|
|
13
20
|
rxn_stoichiometry_matrix,
|
|
@@ -23,6 +30,9 @@ __all__ = [
|
|
|
23
30
|
"__description__",
|
|
24
31
|
"__email__",
|
|
25
32
|
"__license__",
|
|
33
|
+
# configs
|
|
34
|
+
"REACTION_SYMBOLIC_MODES",
|
|
35
|
+
"ReactionMode",
|
|
26
36
|
# app
|
|
27
37
|
"rxn",
|
|
28
38
|
"rxn_stoichiometry_matrix",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# import libs
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
# SECTION: PyThermoDBLink/PyThermoDB
|
|
6
|
+
import math
|
|
7
|
+
DATASOURCE = "datasource"
|
|
8
|
+
EQUATIONSOURCE = "equationsource"
|
|
9
|
+
|
|
10
|
+
# NOTE: universal gas constant [J/mol.K]
|
|
11
|
+
R_CONST_J__molK = 8.314472
|
|
12
|
+
|
|
13
|
+
# NOTE: pi
|
|
14
|
+
PI_CONST = math.pi
|
|
15
|
+
|
|
16
|
+
# NOTE: STP condition
|
|
17
|
+
# pressure [Pa]
|
|
18
|
+
PRESSURE_STP_Pa = 101325
|
|
19
|
+
# temperature [K]
|
|
20
|
+
TEMPERATURE_STP_K = 273.15
|
|
21
|
+
# reference pressure [Pa]
|
|
22
|
+
PRESSURE_REF_Pa = 101325
|
|
23
|
+
# reference temperature [K]
|
|
24
|
+
TEMPERATURE_REF_K = 298.15
|
|
25
|
+
|
|
26
|
+
# SECTION: Periodic Table Elements
|
|
27
|
+
PERIODIC_TABLE_ELEMENTS = [
|
|
28
|
+
"H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne",
|
|
29
|
+
"Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar",
|
|
30
|
+
"K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni",
|
|
31
|
+
"Cu", "Zn", "Ga", "Ge", "As", "Se", "Br", "Kr",
|
|
32
|
+
"Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd",
|
|
33
|
+
"Ag", "Cd", "In", "Sn", "Sb", "Te", "I", "Xe",
|
|
34
|
+
"Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd",
|
|
35
|
+
"Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu",
|
|
36
|
+
"Hf", "Ta", "W", "Re", "Os", "Ir", "Pt",
|
|
37
|
+
"Au", "Hg", "Tl", "Pb", "Bi", "Po", "At", "Rn",
|
|
38
|
+
"Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu",
|
|
39
|
+
"Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr",
|
|
40
|
+
"Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn",
|
|
41
|
+
"Nh", "Fl", "Mc", "Lv", "Ts", "Og",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# SECTION: Reaction
|
|
45
|
+
REACTION_SYMBOLIC_MODES = {
|
|
46
|
+
# irreversible
|
|
47
|
+
"->": ("irreversible", "forward"),
|
|
48
|
+
"=>": ("irreversible", "forward"),
|
|
49
|
+
"→": ("irreversible", "forward"),
|
|
50
|
+
"⇒": ("irreversible", "forward"),
|
|
51
|
+
|
|
52
|
+
# backward irreversible
|
|
53
|
+
# ! not recommended for use in reaction expressions
|
|
54
|
+
"<-": ("irreversible", "backward"),
|
|
55
|
+
"←": ("irreversible", "backward"),
|
|
56
|
+
|
|
57
|
+
# reversible
|
|
58
|
+
"<=>": ("reversible", "forward"),
|
|
59
|
+
"<->": ("reversible", "forward"),
|
|
60
|
+
"⇌": ("reversible", "forward"),
|
|
61
|
+
"↔": ("reversible", "forward"),
|
|
62
|
+
|
|
63
|
+
# thermodynamic equilibrium
|
|
64
|
+
"=": ("equilibrium", "forward"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# NOTE: irreversible reaction mode symbols
|
|
68
|
+
IRREVERSIBLE_REACTION_MODE_SYMBOLS = [
|
|
69
|
+
"->", "=>", "→", "⇒",
|
|
70
|
+
"<-", "←"
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# NOTE: reversible reaction mode symbols
|
|
74
|
+
REVERSIBLE_REACTION_MODE_SYMBOLS = [
|
|
75
|
+
"<=>", "<->", "⇌", "↔"
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
# NOTE: equilibrium reaction mode symbols
|
|
79
|
+
EQUILIBRIUM_REACTION_MODE_SYMBOLS = [
|
|
80
|
+
"="
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# NOTE: all reaction mode symbols
|
|
84
|
+
ALL_REACTION_MODE_SYMBOLS = (
|
|
85
|
+
IRREVERSIBLE_REACTION_MODE_SYMBOLS +
|
|
86
|
+
REVERSIBLE_REACTION_MODE_SYMBOLS +
|
|
87
|
+
EQUILIBRIUM_REACTION_MODE_SYMBOLS
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# NOTE: Reaction Mode
|
|
91
|
+
ReactionMode = Literal["<=>", "=>", "="]
|
|
92
|
+
# NOTE: Reaction Type
|
|
93
|
+
ReactionType = Literal["irreversible", "reversible", "equilibrium"]
|
|
94
|
+
# NOTE: Reaction Direction
|
|
95
|
+
ReactionDirection = Literal["forward", "backward"]
|
|
@@ -8,38 +8,17 @@ from ..configs.constants import (
|
|
|
8
8
|
R_CONST_J__molK,
|
|
9
9
|
PRESSURE_REF_Pa,
|
|
10
10
|
TEMPERATURE_REF_K,
|
|
11
|
+
ReactionMode,
|
|
11
12
|
)
|
|
12
13
|
from .chem_react_utils import ChemReactUtils
|
|
13
14
|
from .reaction_component_mapper import ReactionComponentMapper
|
|
14
15
|
from .reaction_network_analysis import ReactionNetworkAnalysis
|
|
16
|
+
from ..models.reactions import Reactant, Product, PhaseRule
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
# NOTE: logger
|
|
18
20
|
logger = logging.getLogger(__name__)
|
|
19
21
|
|
|
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
22
|
|
|
44
23
|
# SECTION: ChemReact class
|
|
45
24
|
class ChemReact(
|
|
@@ -116,6 +95,8 @@ class ChemReact(
|
|
|
116
95
|
|
|
117
96
|
# NOTE: set reaction mode symbol used by this reaction parser
|
|
118
97
|
self.reaction_mode_symbol = reaction_mode_symbol
|
|
98
|
+
# >> reaction type
|
|
99
|
+
self.reaction_type = self.get_reaction_type(reaction_mode_symbol)
|
|
119
100
|
|
|
120
101
|
# NOTE: initialize component mapping settings
|
|
121
102
|
ReactionComponentMapper.__init__(
|
|
@@ -185,7 +166,15 @@ class ChemReact(
|
|
|
185
166
|
# pattern = r'(\d*\.?\d+)?(\w+)\((\w)\)'
|
|
186
167
|
# pattern = r'(?:(\d*\.?\d+)\s*)?([A-Z][a-zA-Z0-9]*)\s*(?:\((\w)\))?'
|
|
187
168
|
# 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)\))?'
|
|
169
|
+
# 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)\))?'
|
|
170
|
+
|
|
171
|
+
# v2
|
|
172
|
+
pattern = r'(?:(\d*\.?\d+)\s*)?(e|[A-Z][A-Za-z0-9]*(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*(?:[·*]\d*[A-Z][A-Za-z0-9]*(?:\((?!(?:g|l|s|aq)\))[A-Za-z0-9]+\)\d*)*)*)\s*(?:\{?((?:\d+)?[+-])\}?)?\s*(?:\((g|l|s|aq)\))?'
|
|
173
|
+
# ! parse as:
|
|
174
|
+
# 1. Optional coefficient (digits with optional decimal)
|
|
175
|
+
# 2. Molecule formula (starting with uppercase letter, followed by alphanumeric characters)
|
|
176
|
+
# 3. Optional charge (digits with optional sign)
|
|
177
|
+
# 4. Optional state (g, l, s, aq)
|
|
189
178
|
|
|
190
179
|
# SECTION: SECTION: Extract reactants and products
|
|
191
180
|
# Extract reactants
|
|
@@ -193,8 +182,9 @@ class ChemReact(
|
|
|
193
182
|
reactants: List[Reactant] = [
|
|
194
183
|
{
|
|
195
184
|
'coefficient': float(r[0]) if r[0] else float(1),
|
|
196
|
-
'molecule': r[1],
|
|
197
|
-
'
|
|
185
|
+
'molecule': self.parse_molecule(r[1], r[2]),
|
|
186
|
+
'charge': self.parse_charge(r[2]),
|
|
187
|
+
'state': r[3] if r[3] else phase_set,
|
|
198
188
|
'molecule_state': ''
|
|
199
189
|
} for r in reactants_raw
|
|
200
190
|
]
|
|
@@ -227,8 +217,9 @@ class ChemReact(
|
|
|
227
217
|
products: List[Product] = [
|
|
228
218
|
{
|
|
229
219
|
'coefficient': float(p[0]) if p[0] else float(1),
|
|
230
|
-
'molecule': p[1],
|
|
231
|
-
'
|
|
220
|
+
'molecule': self.parse_molecule(p[1], p[2]),
|
|
221
|
+
'charge': self.parse_charge(p[2]),
|
|
222
|
+
'state': p[3] if p[3] else phase_set,
|
|
232
223
|
'molecule_state': ''
|
|
233
224
|
} for p in products_raw
|
|
234
225
|
]
|
|
@@ -302,6 +293,18 @@ class ChemReact(
|
|
|
302
293
|
p['coefficient']
|
|
303
294
|
)
|
|
304
295
|
|
|
296
|
+
# NOTE total carbon count for reactants and products
|
|
297
|
+
total_carbon_count = self.count_total_carbon(
|
|
298
|
+
reactants=reactants,
|
|
299
|
+
products=products
|
|
300
|
+
)
|
|
301
|
+
# ? total reactant carbon count
|
|
302
|
+
total_reactant_carbon_count = total_carbon_count['total_reactant_carbon_count']
|
|
303
|
+
# ? total product carbon count
|
|
304
|
+
total_product_carbon_count = total_carbon_count['total_product_carbon_count']
|
|
305
|
+
# ? net carbon count
|
|
306
|
+
net_carbon_count = total_carbon_count['net_carbon_count']
|
|
307
|
+
|
|
305
308
|
# SECTION: reaction state
|
|
306
309
|
reaction_state = {}
|
|
307
310
|
for r in reactants:
|
|
@@ -311,6 +314,33 @@ class ChemReact(
|
|
|
311
314
|
# set
|
|
312
315
|
reaction_state[p['molecule_state']] = p['state']
|
|
313
316
|
|
|
317
|
+
# SECTION: charge count for each component
|
|
318
|
+
charge_count = {}
|
|
319
|
+
for r in reactants:
|
|
320
|
+
charge_count[r['molecule_state']] = self.count_charge(
|
|
321
|
+
r['molecule'],
|
|
322
|
+
r['coefficient'],
|
|
323
|
+
r['charge']
|
|
324
|
+
)
|
|
325
|
+
for p in products:
|
|
326
|
+
charge_count[p['molecule_state']] = self.count_charge(
|
|
327
|
+
p['molecule'],
|
|
328
|
+
p['coefficient'],
|
|
329
|
+
p['charge']
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# SECTION: total charge count for reactants and products
|
|
333
|
+
total_charge_count = self.count_total_charge(
|
|
334
|
+
reactants=reactants,
|
|
335
|
+
products=products
|
|
336
|
+
)
|
|
337
|
+
# ? total reactant charge
|
|
338
|
+
total_reactant_charge = total_charge_count['total_reactant_charge']
|
|
339
|
+
# ? total product charge
|
|
340
|
+
total_product_charge = total_charge_count['total_product_charge']
|
|
341
|
+
# ? net charge
|
|
342
|
+
net_charge = total_charge_count['net_charge']
|
|
343
|
+
|
|
314
344
|
# NOTE: reaction phase
|
|
315
345
|
# reaction
|
|
316
346
|
reaction_phase = self.determine_reaction_phase(
|
|
@@ -393,6 +423,8 @@ class ChemReact(
|
|
|
393
423
|
res = {
|
|
394
424
|
'name': name,
|
|
395
425
|
'reaction': reaction,
|
|
426
|
+
'reaction_mode_symbol': self.reaction_mode_symbol,
|
|
427
|
+
'reaction_type': self.reaction_type,
|
|
396
428
|
"component_ids": component_ids,
|
|
397
429
|
"all_components": all_components,
|
|
398
430
|
"symbolic_reaction": symbolic_reaction,
|
|
@@ -406,9 +438,16 @@ class ChemReact(
|
|
|
406
438
|
'reaction_stoichiometry_matrix': reaction_stoichiometry_matrix,
|
|
407
439
|
'reaction_stoichiometry_source': stoichiometry_source,
|
|
408
440
|
'carbon_count': carbon_count,
|
|
441
|
+
'total_reactant_carbon_count': total_reactant_carbon_count,
|
|
442
|
+
'total_product_carbon_count': total_product_carbon_count,
|
|
443
|
+
'net_carbon_count': net_carbon_count,
|
|
409
444
|
'reaction_state': reaction_state,
|
|
410
445
|
'reaction_phase': reaction_phase,
|
|
411
446
|
'state_count': state_count,
|
|
447
|
+
'charge_count': charge_count,
|
|
448
|
+
'total_reactant_charge': total_reactant_charge,
|
|
449
|
+
'total_product_charge': total_product_charge,
|
|
450
|
+
'net_charge': net_charge,
|
|
412
451
|
'components': components,
|
|
413
452
|
'map_components': map_components,
|
|
414
453
|
'component_checker': self._component_checker,
|
|
@@ -417,4 +456,3 @@ class ChemReact(
|
|
|
417
456
|
return res
|
|
418
457
|
except Exception as e:
|
|
419
458
|
raise Exception(f"Error analyzing reaction: {e}")
|
|
420
|
-
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# import libs
|
|
2
|
+
import re
|
|
3
|
+
from typing import Dict, List, Optional, Any
|
|
4
|
+
# locals
|
|
5
|
+
from ..models.reactions import Reactant, Product
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# SECTION: ChemReactUtils class
|
|
9
|
+
class ChemReactUtils:
|
|
10
|
+
"""General-purpose helpers for chemical reaction analysis."""
|
|
11
|
+
|
|
12
|
+
# NOTE: supported full phase names
|
|
13
|
+
available_phases = ("gas", "liquid", "aqueous", "solid")
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
available_phases: tuple[str, ...] | None = None,
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Initialize general chemical reaction utility settings.
|
|
21
|
+
"""
|
|
22
|
+
# SECTION: phase configuration
|
|
23
|
+
# NOTE: child classes can override the supported phase names
|
|
24
|
+
if available_phases is not None:
|
|
25
|
+
self.available_phases = available_phases
|
|
26
|
+
|
|
27
|
+
# ! ::: parse molecule
|
|
28
|
+
def parse_molecule(
|
|
29
|
+
self,
|
|
30
|
+
id: str,
|
|
31
|
+
charge: Any
|
|
32
|
+
) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Parse molecule Id and charge to a formatted string. Charge is optional and will be appended using {+} or {-} notation if provided.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
# SECTION: format molecule string
|
|
38
|
+
# NOTE: parse charge to ensure it is in the correct format
|
|
39
|
+
charge = self.parse_charge(charge)
|
|
40
|
+
|
|
41
|
+
# >> check if charge is non-zero, append it to the molecule Id
|
|
42
|
+
if charge == 1:
|
|
43
|
+
return f"{id}{{+}}"
|
|
44
|
+
elif charge == -1:
|
|
45
|
+
return f"{id}{{-}}"
|
|
46
|
+
elif charge > 0:
|
|
47
|
+
return f"{id}{{{charge}+}}"
|
|
48
|
+
elif charge < 0:
|
|
49
|
+
return f"{id}{{{abs(charge)}-}}"
|
|
50
|
+
elif charge == 0:
|
|
51
|
+
return id
|
|
52
|
+
else:
|
|
53
|
+
return id
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise Exception(
|
|
56
|
+
f"Error parsing molecule '{id}' with charge '{charge}': {e}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# ! ::: count carbon
|
|
60
|
+
def count_carbon(self, molecule: str, coefficient: float) -> float:
|
|
61
|
+
"""
|
|
62
|
+
Count the total number of carbon atoms in a molecule,
|
|
63
|
+
scaled by the stoichiometric coefficient.
|
|
64
|
+
|
|
65
|
+
Examples
|
|
66
|
+
--------
|
|
67
|
+
CO2 -> 1 carbon
|
|
68
|
+
C2H6 -> 2 carbons
|
|
69
|
+
CH3COOH -> 2 carbons
|
|
70
|
+
CaCO3 -> 1 carbon
|
|
71
|
+
CuSO4 -> 0 carbon
|
|
72
|
+
C6H12O6 -> 6 carbons
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
# SECTION: validate inputs
|
|
76
|
+
# NOTE: molecule formula must be text for regex parsing
|
|
77
|
+
if not isinstance(molecule, str):
|
|
78
|
+
raise ValueError("Molecule must be a string.")
|
|
79
|
+
|
|
80
|
+
# NOTE: coefficient scales the carbon count
|
|
81
|
+
if not isinstance(coefficient, (int, float)):
|
|
82
|
+
raise ValueError("Coefficient must be an integer or float.")
|
|
83
|
+
|
|
84
|
+
# SECTION: find carbon atoms
|
|
85
|
+
# C(?![a-z]) ensures C is not part of Ca, Cu, Cl, Co, ...
|
|
86
|
+
# (\d*) captures an optional numeric subscript after C
|
|
87
|
+
matches = re.findall(r'C(?![a-z])(\d*)', molecule)
|
|
88
|
+
|
|
89
|
+
# SECTION: calculate carbon count
|
|
90
|
+
carbon_count = sum(
|
|
91
|
+
int(count) if count else 1
|
|
92
|
+
for count in matches
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return carbon_count * coefficient
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise Exception(
|
|
98
|
+
f"Error counting carbon in molecule '{molecule}': {e}")
|
|
99
|
+
|
|
100
|
+
# ! ::: count total carbon
|
|
101
|
+
def count_total_carbon(
|
|
102
|
+
self,
|
|
103
|
+
reactants: List[Reactant],
|
|
104
|
+
products: List[Product]
|
|
105
|
+
) -> Dict[str, float]:
|
|
106
|
+
"""
|
|
107
|
+
Count the total number of carbon atoms in reactants and products.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
# SECTION: calculate total carbon for reactants
|
|
111
|
+
total_reactant_carbon = sum(
|
|
112
|
+
self.count_carbon(r['molecule'], r['coefficient'])
|
|
113
|
+
for r in reactants
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# SECTION: calculate total carbon for products
|
|
117
|
+
total_product_carbon = sum(
|
|
118
|
+
self.count_carbon(p['molecule'], p['coefficient'])
|
|
119
|
+
for p in products
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# NOTE: return total carbon counts as a dictionary
|
|
123
|
+
return {
|
|
124
|
+
'total_reactant_carbon_count': total_reactant_carbon,
|
|
125
|
+
'total_product_carbon_count': total_product_carbon,
|
|
126
|
+
'net_carbon_count': total_product_carbon - total_reactant_carbon
|
|
127
|
+
}
|
|
128
|
+
except Exception as e:
|
|
129
|
+
raise Exception(f"Error counting total carbon in reaction: {e}")
|
|
130
|
+
|
|
131
|
+
# ! :::phase rule analysis
|
|
132
|
+
def phase_rule_analysis(self, phase_rule: Optional[str] = None) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Analyze the phase rule of a reaction.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
# SECTION: default phase rule
|
|
138
|
+
# NOTE: empty means component states must be present in the reaction
|
|
139
|
+
if phase_rule is None or phase_rule == 'None':
|
|
140
|
+
return 'empty'
|
|
141
|
+
|
|
142
|
+
# SECTION: validate phase rule
|
|
143
|
+
# ? keep this aligned with PhaseRule in chem_react.py
|
|
144
|
+
if phase_rule not in self.available_phases:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"Phase rule must be {', '.join(self.available_phases)}.")
|
|
147
|
+
|
|
148
|
+
# SECTION: convert full phase name to reaction state symbol
|
|
149
|
+
if phase_rule == 'gas':
|
|
150
|
+
phase_symbol = 'g'
|
|
151
|
+
elif phase_rule == 'liquid':
|
|
152
|
+
phase_symbol = 'l'
|
|
153
|
+
elif phase_rule == 'aqueous':
|
|
154
|
+
phase_symbol = 'aq'
|
|
155
|
+
elif phase_rule == 'solid':
|
|
156
|
+
phase_symbol = 's'
|
|
157
|
+
else:
|
|
158
|
+
phase_symbol = 'empty'
|
|
159
|
+
|
|
160
|
+
# NOTE: return compact state symbol used by parsed components
|
|
161
|
+
return phase_symbol
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise Exception(f"Error analyzing phase rule: {e}")
|
|
164
|
+
|
|
165
|
+
# ! ::: state name set
|
|
166
|
+
def state_name_set(self, state_set: set) -> List[str]:
|
|
167
|
+
"""
|
|
168
|
+
Convert state set to full names.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
# SECTION: state name mapping
|
|
172
|
+
# NOTE: keys match state symbols parsed from reaction strings
|
|
173
|
+
state_dict = {
|
|
174
|
+
'g': 'gas',
|
|
175
|
+
'l': 'liquid',
|
|
176
|
+
'aq': 'aqueous',
|
|
177
|
+
's': 'solid'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# NOTE: convert each compact symbol to its full phase name
|
|
181
|
+
return [state_dict[state] for state in state_set]
|
|
182
|
+
except Exception as e:
|
|
183
|
+
raise Exception(f"Error converting state set to full names: {e}")
|
|
184
|
+
|
|
185
|
+
# ! ::: determine reaction phase
|
|
186
|
+
def determine_reaction_phase(self, reaction_dict: Dict[str, str]) -> str:
|
|
187
|
+
"""
|
|
188
|
+
Determine the phase of a reaction based on component states.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
# SECTION: collect unique states
|
|
192
|
+
available_states = set(reaction_dict.values())
|
|
193
|
+
# NOTE: convert state symbols before formatting phase text
|
|
194
|
+
state_names = self.state_name_set(available_states)
|
|
195
|
+
|
|
196
|
+
# SECTION: determine reaction phase label
|
|
197
|
+
if len(state_names) == 1:
|
|
198
|
+
# NOTE: single-phase reaction
|
|
199
|
+
return f'{state_names[0]}'
|
|
200
|
+
else:
|
|
201
|
+
# NOTE: multi-phase reaction
|
|
202
|
+
return f'{"-".join(state_names)}'
|
|
203
|
+
except Exception as e:
|
|
204
|
+
raise Exception(f"Error determining reaction phase: {e}")
|
|
205
|
+
|
|
206
|
+
# ! ::: count reaction states
|
|
207
|
+
def count_reaction_states(self, reaction_dict: Dict[str, str]) -> Dict[str, int]:
|
|
208
|
+
"""
|
|
209
|
+
Count the number of component states in a reaction.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
# SECTION: collect component states
|
|
213
|
+
available_states = reaction_dict.values()
|
|
214
|
+
# NOTE: initialize all supported state buckets
|
|
215
|
+
state_count = {
|
|
216
|
+
'g': 0,
|
|
217
|
+
'l': 0,
|
|
218
|
+
'aq': 0,
|
|
219
|
+
's': 0
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# SECTION: count state occurrences
|
|
223
|
+
for state in available_states:
|
|
224
|
+
# ! ignore unsupported states instead of adding new keys
|
|
225
|
+
if state in state_count:
|
|
226
|
+
state_count[state] += 1
|
|
227
|
+
|
|
228
|
+
# NOTE: return counts for every supported state symbol
|
|
229
|
+
return state_count
|
|
230
|
+
except Exception as e:
|
|
231
|
+
raise Exception(f"Error determining reaction phase: {e}")
|
|
232
|
+
|
|
233
|
+
# ! ::: reaction types
|
|
234
|
+
def get_reaction_type(self, reaction_mode_symbol: str) -> str:
|
|
235
|
+
"""
|
|
236
|
+
Determine the type of reaction based on the reaction mode symbol.
|
|
237
|
+
|
|
238
|
+
Reaction Mode Symbols:
|
|
239
|
+
- `Reversible`: "<=>"
|
|
240
|
+
- `Irreversible`: "=>"
|
|
241
|
+
- `Equilibrium`: "="
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
# SECTION: validate reaction mode symbol
|
|
245
|
+
if reaction_mode_symbol not in ("<=>", "=>", "="):
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"Invalid reaction mode symbol: {reaction_mode_symbol}")
|
|
248
|
+
|
|
249
|
+
# SECTION: determine reaction type
|
|
250
|
+
if reaction_mode_symbol == "<=>":
|
|
251
|
+
return "reversible"
|
|
252
|
+
elif reaction_mode_symbol == "=>":
|
|
253
|
+
return "irreversible"
|
|
254
|
+
elif reaction_mode_symbol == "=":
|
|
255
|
+
return "equilibrium"
|
|
256
|
+
else:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"Unknown reaction mode symbol: {reaction_mode_symbol}")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
raise Exception(f"Error determining reaction type: {e}")
|
|
261
|
+
|
|
262
|
+
# ! ::: parse charge
|
|
263
|
+
def parse_charge(self, charge: str) -> int:
|
|
264
|
+
"""
|
|
265
|
+
Convert reaction charge notation to an integer charge.
|
|
266
|
+
"""
|
|
267
|
+
try:
|
|
268
|
+
# SECTION: empty or missing charge
|
|
269
|
+
if charge == "":
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
# SECTION: normalize notation
|
|
273
|
+
charge = charge.strip()
|
|
274
|
+
if charge in ("+", "-"):
|
|
275
|
+
return 1 if charge == "+" else -1
|
|
276
|
+
|
|
277
|
+
# SECTION: charge magnitude with trailing sign, e.g. 2+ or 3-
|
|
278
|
+
match = re.fullmatch(r"(\d+)([+-])", charge)
|
|
279
|
+
if match:
|
|
280
|
+
magnitude = int(match.group(1))
|
|
281
|
+
sign = match.group(2)
|
|
282
|
+
return magnitude if sign == "+" else -magnitude
|
|
283
|
+
|
|
284
|
+
# SECTION: signed integer fallback, e.g. +2 or -2
|
|
285
|
+
return int(charge)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
raise Exception(f"Error parsing charge '{charge}': {e}")
|
|
288
|
+
|
|
289
|
+
# ! ::: count charge
|
|
290
|
+
def count_charge(
|
|
291
|
+
self,
|
|
292
|
+
molecule: str,
|
|
293
|
+
coefficient: float,
|
|
294
|
+
charge: int
|
|
295
|
+
) -> float:
|
|
296
|
+
"""
|
|
297
|
+
Count the total charge of a molecule based on its charge and coefficient.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
# SECTION: validate inputs
|
|
301
|
+
if not isinstance(molecule, str):
|
|
302
|
+
raise ValueError("Molecule must be a string.")
|
|
303
|
+
if not isinstance(coefficient, (int, float)):
|
|
304
|
+
raise ValueError("Coefficient must be an integer or float.")
|
|
305
|
+
if not isinstance(charge, int):
|
|
306
|
+
raise ValueError("Charge must be an integer.")
|
|
307
|
+
|
|
308
|
+
# SECTION: calculate total charge
|
|
309
|
+
total_charge = charge * coefficient
|
|
310
|
+
return total_charge
|
|
311
|
+
except Exception as e:
|
|
312
|
+
raise Exception(
|
|
313
|
+
f"Error counting charge in molecule '{molecule}': {e}")
|
|
314
|
+
|
|
315
|
+
# ! ::: count total charge in reaction
|
|
316
|
+
def count_total_charge(
|
|
317
|
+
self,
|
|
318
|
+
reactants: List[Reactant],
|
|
319
|
+
products: List[Product]
|
|
320
|
+
) -> Dict[str, float]:
|
|
321
|
+
"""
|
|
322
|
+
Count the total charge of reactants and products in a reaction.
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
# SECTION: calculate total charge for reactants
|
|
326
|
+
total_reactant_charge = sum(
|
|
327
|
+
self.count_charge(r['molecule'], r['coefficient'], r['charge'])
|
|
328
|
+
for r in reactants
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# SECTION: calculate total charge for products
|
|
332
|
+
total_product_charge = sum(
|
|
333
|
+
self.count_charge(p['molecule'], p['coefficient'], p['charge'])
|
|
334
|
+
for p in products
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# NOTE: return total charges as a dictionary
|
|
338
|
+
return {
|
|
339
|
+
'total_reactant_charge': total_reactant_charge,
|
|
340
|
+
'total_product_charge': total_product_charge,
|
|
341
|
+
'net_charge': total_product_charge - total_reactant_charge
|
|
342
|
+
}
|
|
343
|
+
except Exception as e:
|
|
344
|
+
raise Exception(f"Error counting total charge in reaction: {e}")
|
|
@@ -5,11 +5,9 @@ from typing import Any, Dict, Optional, List
|
|
|
5
5
|
from pydantic import BaseModel, Field, computed_field, model_validator
|
|
6
6
|
from pythermodb_settings.models import Component, ComponentKey
|
|
7
7
|
# local imports
|
|
8
|
-
from ..
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
PhaseRule
|
|
12
|
-
)
|
|
8
|
+
from ..configs.constants import ReactionMode
|
|
9
|
+
from ..core.chem_react import ChemReact
|
|
10
|
+
from ..utils.reaction_tools import get_reaction_mode_symbol, normalize_reaction_expression
|
|
13
11
|
|
|
14
12
|
# NOTE: set up logger
|
|
15
13
|
logger = logging.getLogger(__name__)
|
|
@@ -29,6 +27,8 @@ class Reaction(BaseModel):
|
|
|
29
27
|
The symbol used to separate reactants and products in a reaction equation.
|
|
30
28
|
analysis : Dict[str, Any]
|
|
31
29
|
A dictionary containing the analysis results of the reaction.
|
|
30
|
+
component_keys : List[ComponentKey]
|
|
31
|
+
The key used to identify components in the reaction analysis, with a default value of ["Formula-State", "Name-State", "Name-Formula", "Name-Formula-State"].
|
|
32
32
|
|
|
33
33
|
Properties
|
|
34
34
|
----------
|
|
@@ -87,23 +87,23 @@ class Reaction(BaseModel):
|
|
|
87
87
|
"Formula-State",
|
|
88
88
|
"Name-State",
|
|
89
89
|
"Name-Formula",
|
|
90
|
+
"Name-Formula-State",
|
|
90
91
|
],
|
|
91
92
|
description="The key used to identify components in the reaction analysis."
|
|
92
93
|
)
|
|
93
94
|
|
|
94
95
|
@model_validator(mode="after")
|
|
95
96
|
def _run_existing_analysis(self):
|
|
96
|
-
# NOTE:
|
|
97
|
-
if
|
|
98
|
-
self.reaction_mode_symbol =
|
|
99
|
-
elif "=>" in self.reaction:
|
|
100
|
-
self.reaction_mode_symbol = "=>"
|
|
101
|
-
elif "=" in self.reaction:
|
|
102
|
-
self.reaction_mode_symbol = "="
|
|
97
|
+
# NOTE: validate reaction mode symbol
|
|
98
|
+
if self.reaction_mode_symbol is None:
|
|
99
|
+
self.reaction_mode_symbol = get_reaction_mode_symbol(self.reaction)
|
|
103
100
|
else:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
# validate provided reaction mode symbol
|
|
102
|
+
if self.reaction_mode_symbol not in ["<=>", "=>", "="]:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Invalid reaction mode symbol: {self.reaction_mode_symbol}. "
|
|
105
|
+
f"Must be one of ['<=>', '=>', '=']."
|
|
106
|
+
)
|
|
107
107
|
|
|
108
108
|
# NOTE: analyze reaction
|
|
109
109
|
util = ChemReact(
|
|
@@ -112,6 +112,9 @@ class Reaction(BaseModel):
|
|
|
112
112
|
component_keys=self.component_keys
|
|
113
113
|
)
|
|
114
114
|
|
|
115
|
+
# NOTE: normalize reaction string
|
|
116
|
+
self.reaction = normalize_reaction_expression(self.reaction)
|
|
117
|
+
|
|
115
118
|
# NOTE: perform analysis
|
|
116
119
|
self.analysis = util.analyze_reaction(
|
|
117
120
|
reaction_pack={
|
|
@@ -119,6 +122,8 @@ class Reaction(BaseModel):
|
|
|
119
122
|
"reaction": self.reaction
|
|
120
123
|
},
|
|
121
124
|
)
|
|
125
|
+
|
|
126
|
+
# ! return self to allow for method chaining or further processing if needed
|
|
122
127
|
return self
|
|
123
128
|
|
|
124
129
|
@computed_field
|
|
@@ -126,6 +131,11 @@ class Reaction(BaseModel):
|
|
|
126
131
|
def symbolic_reaction(self) -> str:
|
|
127
132
|
return self.analysis.get("symbolic_reaction", "")
|
|
128
133
|
|
|
134
|
+
@computed_field
|
|
135
|
+
@property
|
|
136
|
+
def reaction_type(self) -> str:
|
|
137
|
+
return self.analysis.get("reaction_type", "")
|
|
138
|
+
|
|
129
139
|
@computed_field
|
|
130
140
|
@property
|
|
131
141
|
def symbolic_unbalanced_reaction(self) -> str:
|
|
@@ -176,6 +186,21 @@ class Reaction(BaseModel):
|
|
|
176
186
|
def carbon_count(self) -> Dict[str, float]:
|
|
177
187
|
return self.analysis.get("carbon_count", {})
|
|
178
188
|
|
|
189
|
+
@computed_field
|
|
190
|
+
@property
|
|
191
|
+
def net_carbon_count(self) -> float:
|
|
192
|
+
return self.analysis.get("net_carbon_count", 0.0)
|
|
193
|
+
|
|
194
|
+
@computed_field
|
|
195
|
+
@property
|
|
196
|
+
def total_reactant_carbon_count(self) -> float:
|
|
197
|
+
return self.analysis.get("total_reactant_carbon_count", 0.0)
|
|
198
|
+
|
|
199
|
+
@computed_field
|
|
200
|
+
@property
|
|
201
|
+
def total_product_carbon_count(self) -> float:
|
|
202
|
+
return self.analysis.get("total_product_carbon_count", 0.0)
|
|
203
|
+
|
|
179
204
|
@computed_field
|
|
180
205
|
@property
|
|
181
206
|
def reaction_state(self) -> Dict[str, str]:
|
|
@@ -191,6 +216,26 @@ class Reaction(BaseModel):
|
|
|
191
216
|
def state_count(self) -> Dict[str, int]:
|
|
192
217
|
return self.analysis.get("state_count", {})
|
|
193
218
|
|
|
219
|
+
@computed_field
|
|
220
|
+
@property
|
|
221
|
+
def charge_count(self) -> Dict[str, int]:
|
|
222
|
+
return self.analysis.get("charge_count", {})
|
|
223
|
+
|
|
224
|
+
@computed_field
|
|
225
|
+
@property
|
|
226
|
+
def total_reactant_charge(self) -> int:
|
|
227
|
+
return self.analysis.get("total_reactant_charge", 0)
|
|
228
|
+
|
|
229
|
+
@computed_field
|
|
230
|
+
@property
|
|
231
|
+
def total_product_charge(self) -> int:
|
|
232
|
+
return self.analysis.get("total_product_charge", 0)
|
|
233
|
+
|
|
234
|
+
@computed_field
|
|
235
|
+
@property
|
|
236
|
+
def net_charge(self) -> int:
|
|
237
|
+
return self.analysis.get("net_charge", 0)
|
|
238
|
+
|
|
194
239
|
@computed_field
|
|
195
240
|
@property
|
|
196
241
|
def component_ids(self) -> Dict[str, int]:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# import libs
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import TypedDict, Literal, Dict, List, Optional
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from pythermodb_settings.models import Component
|
|
6
|
+
# locals
|
|
7
|
+
|
|
8
|
+
# SECTION: Models
|
|
9
|
+
# NOTE: Phase Rule
|
|
10
|
+
PhaseRule = Literal["gas", "liquid", "aqueous", "solid"]
|
|
11
|
+
|
|
12
|
+
# NOTE: reactants
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Reactant(TypedDict):
|
|
16
|
+
coefficient: float
|
|
17
|
+
molecule: str
|
|
18
|
+
charge: int
|
|
19
|
+
state: str
|
|
20
|
+
molecule_state: str
|
|
21
|
+
|
|
22
|
+
# NOTE: products
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Product(TypedDict):
|
|
26
|
+
coefficient: float
|
|
27
|
+
molecule: str
|
|
28
|
+
charge: int
|
|
29
|
+
state: str
|
|
30
|
+
molecule_state: str
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# import libs
|
|
2
|
+
import logging
|
|
3
|
+
from typing import cast, NamedTuple
|
|
4
|
+
# locals
|
|
5
|
+
from ..configs.constants import (
|
|
6
|
+
ReactionMode,
|
|
7
|
+
ReactionDirection,
|
|
8
|
+
ReactionType,
|
|
9
|
+
REACTION_SYMBOLIC_MODES,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# NOTE: logger
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# ! reaction mode
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReactionSymbolInfo(NamedTuple):
|
|
19
|
+
reaction: str
|
|
20
|
+
symbol: str
|
|
21
|
+
type: ReactionType
|
|
22
|
+
mode: ReactionMode
|
|
23
|
+
direction: ReactionDirection
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_reaction(
|
|
27
|
+
reaction: str,
|
|
28
|
+
) -> ReactionSymbolInfo:
|
|
29
|
+
"""
|
|
30
|
+
Determine the reaction mode symbol, type, and direction from a given reaction string.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
reaction : str
|
|
35
|
+
The chemical reaction equation as a string.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
ReactionSymbolInfo
|
|
40
|
+
A named tuple containing the reaction mode symbol, type, and direction.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
# SECTION: check for reaction mode symbols in the reaction string
|
|
44
|
+
rxn_direction = None
|
|
45
|
+
rxn_mode = None
|
|
46
|
+
rxn_symbol = None
|
|
47
|
+
|
|
48
|
+
# Longest symbols first to avoid "=" matching "<=>"
|
|
49
|
+
symbols = sorted(
|
|
50
|
+
REACTION_SYMBOLIC_MODES,
|
|
51
|
+
key=len,
|
|
52
|
+
reverse=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for symbol in symbols:
|
|
56
|
+
if symbol in reaction:
|
|
57
|
+
mode, direction = REACTION_SYMBOLIC_MODES[symbol]
|
|
58
|
+
rxn_mode = mode
|
|
59
|
+
rxn_direction = direction
|
|
60
|
+
rxn_symbol = symbol
|
|
61
|
+
# replace the symbol with :::
|
|
62
|
+
reaction = reaction.replace(symbol, ":::")
|
|
63
|
+
# break after finding the first valid symbol
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
# NOTE: if a valid reaction mode symbol is found, return it
|
|
67
|
+
if (
|
|
68
|
+
rxn_symbol is not None and
|
|
69
|
+
rxn_symbol is not None and
|
|
70
|
+
rxn_direction is not None
|
|
71
|
+
):
|
|
72
|
+
if rxn_mode == "irreversible":
|
|
73
|
+
return ReactionSymbolInfo(
|
|
74
|
+
reaction=reaction.replace(":::", '=>'),
|
|
75
|
+
symbol=rxn_symbol,
|
|
76
|
+
type=cast(ReactionType, "irreversible"),
|
|
77
|
+
mode=cast(ReactionMode, "=>"),
|
|
78
|
+
direction=cast(ReactionDirection, "forward")
|
|
79
|
+
)
|
|
80
|
+
elif rxn_mode == "reversible":
|
|
81
|
+
return ReactionSymbolInfo(
|
|
82
|
+
reaction=reaction.replace(":::", '<=>'),
|
|
83
|
+
symbol=rxn_symbol,
|
|
84
|
+
type=cast(ReactionType, "reversible"),
|
|
85
|
+
mode=cast(ReactionMode, "<=>"),
|
|
86
|
+
direction=cast(ReactionDirection, "forward")
|
|
87
|
+
)
|
|
88
|
+
elif rxn_mode == "equilibrium":
|
|
89
|
+
return ReactionSymbolInfo(
|
|
90
|
+
reaction=reaction.replace(":::", '='),
|
|
91
|
+
symbol=rxn_symbol,
|
|
92
|
+
type=cast(ReactionType, "equilibrium"),
|
|
93
|
+
mode=cast(ReactionMode, "="),
|
|
94
|
+
direction=cast(ReactionDirection, "forward")
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"Unknown reaction type: {rxn_mode}")
|
|
98
|
+
|
|
99
|
+
# NOTE: no valid reaction mode found
|
|
100
|
+
raise
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise Exception(f"Error determining reaction mode: {e}")
|
|
103
|
+
|
|
104
|
+
# ! get reaction mode symbol
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_reaction_mode_symbol(reaction: str) -> ReactionMode:
|
|
108
|
+
"""
|
|
109
|
+
Determine the reaction mode symbol from a given reaction string.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
reaction : str
|
|
114
|
+
The chemical reaction equation as a string.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
ReactionMode
|
|
119
|
+
The reaction mode symbol.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
# SECTION: check for reaction mode symbols in the reaction string
|
|
123
|
+
rxn_symbol_info = check_reaction(reaction)
|
|
124
|
+
return rxn_symbol_info.mode
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise Exception(f"Error determining reaction mode symbol: {e}")
|
|
127
|
+
|
|
128
|
+
# ! get reaction expression
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def normalize_reaction_expression(reaction: str) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Normalize the reaction expression by removing the reaction mode symbol and updating the symbol to a standard form including "<=>", "=>", or "=".
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
reaction : str
|
|
138
|
+
The chemical reaction equation as a string.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
str
|
|
143
|
+
The reaction expression without the mode symbol.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
# SECTION: check for reaction mode symbols in the reaction string
|
|
147
|
+
rxn_symbol_info = check_reaction(reaction)
|
|
148
|
+
return rxn_symbol_info.reaction
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise Exception(f"Error determining reaction expression: {e}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyreactlab-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -21,6 +21,8 @@ pyreactlab_core/docs/chem_balance.py
|
|
|
21
21
|
pyreactlab_core/docs/chem_utils.py
|
|
22
22
|
pyreactlab_core/models/__init__.py
|
|
23
23
|
pyreactlab_core/models/reaction.py
|
|
24
|
+
pyreactlab_core/models/reactions.py
|
|
24
25
|
pyreactlab_core/utils/__init__.py
|
|
25
26
|
pyreactlab_core/utils/component_tools.py
|
|
27
|
+
pyreactlab_core/utils/reaction_tools.py
|
|
26
28
|
pyreactlab_core/utils/tools.py
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# import libs
|
|
2
|
-
|
|
3
|
-
# SECTION: PyThermoDBLink/PyThermoDB
|
|
4
|
-
import math
|
|
5
|
-
DATASOURCE = "datasource"
|
|
6
|
-
EQUATIONSOURCE = "equationsource"
|
|
7
|
-
|
|
8
|
-
# NOTE: universal gas constant [J/mol.K]
|
|
9
|
-
R_CONST_J__molK = 8.314472
|
|
10
|
-
|
|
11
|
-
# NOTE: pi
|
|
12
|
-
PI_CONST = math.pi
|
|
13
|
-
|
|
14
|
-
# NOTE: STP condition
|
|
15
|
-
# pressure [Pa]
|
|
16
|
-
PRESSURE_STP_Pa = 101325
|
|
17
|
-
# temperature [K]
|
|
18
|
-
TEMPERATURE_STP_K = 273.15
|
|
19
|
-
# reference pressure [Pa]
|
|
20
|
-
PRESSURE_REF_Pa = 101325
|
|
21
|
-
# reference temperature [K]
|
|
22
|
-
TEMPERATURE_REF_K = 298.15
|
|
23
|
-
|
|
24
|
-
# SECTION: Periodic Table Elements
|
|
25
|
-
PERIODIC_TABLE_ELEMENTS = [
|
|
26
|
-
"H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne",
|
|
27
|
-
"Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar",
|
|
28
|
-
"K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni",
|
|
29
|
-
"Cu", "Zn", "Ga", "Ge", "As", "Se", "Br", "Kr",
|
|
30
|
-
"Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd",
|
|
31
|
-
"Ag", "Cd", "In", "Sn", "Sb", "Te", "I", "Xe",
|
|
32
|
-
"Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd",
|
|
33
|
-
"Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu",
|
|
34
|
-
"Hf", "Ta", "W", "Re", "Os", "Ir", "Pt",
|
|
35
|
-
"Au", "Hg", "Tl", "Pb", "Bi", "Po", "At", "Rn",
|
|
36
|
-
"Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu",
|
|
37
|
-
"Am", "Cm", "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr",
|
|
38
|
-
"Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn",
|
|
39
|
-
"Nh", "Fl", "Mc", "Lv", "Ts", "Og",
|
|
40
|
-
]
|
|
@@ -1,149 +0,0 @@
|
|
|
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}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_component_mapper.py
RENAMED
|
File without changes
|
{pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_network_analysis.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|