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.
Files changed (32) hide show
  1. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/PKG-INFO +1 -1
  2. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyproject.toml +1 -1
  3. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/__init__.py +11 -1
  4. pyreactlab_core-0.3.0/pyreactlab_core/configs/constants.py +95 -0
  5. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/info.py +1 -1
  6. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/chem_react.py +67 -29
  7. pyreactlab_core-0.3.0/pyreactlab_core/core/chem_react_utils.py +344 -0
  8. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/models/reaction.py +60 -15
  9. pyreactlab_core-0.3.0/pyreactlab_core/models/reactions.py +30 -0
  10. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/component_tools.py +1 -2
  11. pyreactlab_core-0.3.0/pyreactlab_core/utils/reaction_tools.py +150 -0
  12. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/tools.py +1 -0
  13. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/PKG-INFO +1 -1
  14. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/SOURCES.txt +2 -0
  15. pyreactlab_core-0.1.9/pyreactlab_core/configs/constants.py +0 -40
  16. pyreactlab_core-0.1.9/pyreactlab_core/core/chem_react_utils.py +0 -149
  17. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/LICENSE +0 -0
  18. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/README.md +0 -0
  19. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/app.py +0 -0
  20. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/__init__.py +0 -0
  21. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/__init__.py +0 -0
  22. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_component_mapper.py +0 -0
  23. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_network_analysis.py +0 -0
  24. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/__init__.py +0 -0
  25. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/chem_balance.py +0 -0
  26. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/chem_utils.py +0 -0
  27. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/models/__init__.py +0 -0
  28. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/__init__.py +0 -0
  29. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/dependency_links.txt +0 -0
  30. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/requires.txt +0 -0
  31. {pyreactlab_core-0.1.9 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/top_level.txt +0 -0
  32. {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.1.9
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.1.9"
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"]
@@ -1,5 +1,5 @@
1
1
  # version
2
- __version__ = "0.1.9"
2
+ __version__ = "0.3.0"
3
3
  # author
4
4
  __author__ = "Sina Gilassi"
5
5
  # email
@@ -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
- 'state': r[2] if r[2] else phase_set,
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
- 'state': p[2] if p[2] else phase_set,
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 ..core.chem_react import (
9
- ChemReact,
10
- ReactionMode,
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: check reaction mode symbol
97
- if "<=>" in self.reaction:
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
- raise ValueError(
105
- f"Invalid reaction format in reaction: {self.reaction}"
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
@@ -1,6 +1,5 @@
1
1
  # import libs
2
- import logging
3
- from typing import Dict, Any, List, Optional, Literal
2
+ from typing import List
4
3
  from pythermodb_settings.models import Component, ComponentKey
5
4
  from pythermodb_settings.utils import set_component_id
6
5
 
@@ -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}")
@@ -2,6 +2,7 @@
2
2
  import logging
3
3
  from typing import Literal, Optional
4
4
 
5
+
5
6
  # setup logger
6
7
  logger = logging.getLogger(__name__)
7
8
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyreactlab-core
3
- Version: 0.1.9
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