pyreactlab-core 0.2.0__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 (31) hide show
  1. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/PKG-INFO +1 -1
  2. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyproject.toml +1 -1
  3. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/info.py +1 -1
  4. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/core/chem_react.py +62 -25
  5. pyreactlab_core-0.3.0/pyreactlab_core/core/chem_react_utils.py +344 -0
  6. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/models/reaction.py +37 -1
  7. pyreactlab_core-0.3.0/pyreactlab_core/models/reactions.py +30 -0
  8. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/component_tools.py +1 -2
  9. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/reaction_tools.py +1 -4
  10. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/tools.py +1 -0
  11. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/PKG-INFO +1 -1
  12. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/SOURCES.txt +1 -0
  13. pyreactlab_core-0.2.0/pyreactlab_core/core/chem_react_utils.py +0 -181
  14. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/LICENSE +0 -0
  15. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/README.md +0 -0
  16. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/__init__.py +0 -0
  17. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/app.py +0 -0
  18. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/__init__.py +0 -0
  19. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/configs/constants.py +0 -0
  20. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/core/__init__.py +0 -0
  21. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_component_mapper.py +0 -0
  22. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/core/reaction_network_analysis.py +0 -0
  23. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/__init__.py +0 -0
  24. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/chem_balance.py +0 -0
  25. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/docs/chem_utils.py +0 -0
  26. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/models/__init__.py +0 -0
  27. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core/utils/__init__.py +0 -0
  28. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/dependency_links.txt +0 -0
  29. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/requires.txt +0 -0
  30. {pyreactlab_core-0.2.0 → pyreactlab_core-0.3.0}/pyreactlab_core.egg-info/top_level.txt +0 -0
  31. {pyreactlab_core-0.2.0 → 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.2.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.2.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,5 +1,5 @@
1
1
  # version
2
- __version__ = "0.2.0"
2
+ __version__ = "0.3.0"
3
3
  # author
4
4
  __author__ = "Sina Gilassi"
5
5
  # email
@@ -13,31 +13,12 @@ from ..configs.constants import (
13
13
  from .chem_react_utils import ChemReactUtils
14
14
  from .reaction_component_mapper import ReactionComponentMapper
15
15
  from .reaction_network_analysis import ReactionNetworkAnalysis
16
+ from ..models.reactions import Reactant, Product, PhaseRule
16
17
 
17
18
 
18
19
  # NOTE: logger
19
20
  logger = logging.getLogger(__name__)
20
21
 
21
- # NOTE: Phase Rule
22
- PhaseRule = Literal["gas", "liquid", "aqueous", "solid"]
23
-
24
- # SECTION: Models
25
- # NOTE: reactants
26
-
27
-
28
- class Reactant(TypedDict):
29
- coefficient: float
30
- molecule: str
31
- state: str
32
- molecule_state: str
33
-
34
-
35
- class Product(TypedDict):
36
- coefficient: float
37
- molecule: str
38
- state: str
39
- molecule_state: str
40
-
41
22
 
42
23
  # SECTION: ChemReact class
43
24
  class ChemReact(
@@ -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(
@@ -408,9 +438,16 @@ class ChemReact(
408
438
  'reaction_stoichiometry_matrix': reaction_stoichiometry_matrix,
409
439
  'reaction_stoichiometry_source': stoichiometry_source,
410
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,
411
444
  'reaction_state': reaction_state,
412
445
  'reaction_phase': reaction_phase,
413
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,
414
451
  'components': components,
415
452
  'map_components': map_components,
416
453
  'component_checker': self._component_checker,
@@ -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}")
@@ -28,7 +28,7 @@ class Reaction(BaseModel):
28
28
  analysis : Dict[str, Any]
29
29
  A dictionary containing the analysis results of the reaction.
30
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"].
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,6 +87,7 @@ 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
  )
@@ -185,6 +186,21 @@ class Reaction(BaseModel):
185
186
  def carbon_count(self) -> Dict[str, float]:
186
187
  return self.analysis.get("carbon_count", {})
187
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
+
188
204
  @computed_field
189
205
  @property
190
206
  def reaction_state(self) -> Dict[str, str]:
@@ -200,6 +216,26 @@ class Reaction(BaseModel):
200
216
  def state_count(self) -> Dict[str, int]:
201
217
  return self.analysis.get("state_count", {})
202
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
+
203
239
  @computed_field
204
240
  @property
205
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
 
@@ -1,11 +1,8 @@
1
1
  # import libs
2
2
  import logging
3
- from typing import Any, Dict, List, Optional, cast, NamedTuple
3
+ from typing import cast, NamedTuple
4
4
  # locals
5
5
  from ..configs.constants import (
6
- IRREVERSIBLE_REACTION_MODE_SYMBOLS,
7
- REVERSIBLE_REACTION_MODE_SYMBOLS,
8
- EQUILIBRIUM_REACTION_MODE_SYMBOLS,
9
6
  ReactionMode,
10
7
  ReactionDirection,
11
8
  ReactionType,
@@ -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.2.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,7 @@ 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
26
27
  pyreactlab_core/utils/reaction_tools.py
@@ -1,181 +0,0 @@
1
- # import libs
2
- import re
3
- from typing import Dict, List, Optional
4
- # locals
5
- from ..configs.constants import REACTION_SYMBOLIC_MODES
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
- def count_carbon(self, molecule: str, coefficient: float) -> float:
28
- """
29
- Count the number of carbon atoms in a molecule.
30
- """
31
- try:
32
- # SECTION: validate inputs
33
- # NOTE: molecule formula must be text for regex parsing
34
- if not isinstance(molecule, str):
35
- raise ValueError("Molecule must be a string.")
36
-
37
- # NOTE: coefficient scales the carbon count
38
- if not isinstance(coefficient, (int, float)):
39
- raise ValueError("Coefficient must be an integer or float.")
40
-
41
- # SECTION: carbon symbol matching
42
- # ! do not count lowercase carbon inside another element symbol
43
- if re.search(r'C(?![a-z])', molecule):
44
- # NOTE: multiply atom occurrences by stoichiometric coefficient
45
- carbon_count = len(re.findall(
46
- r'C(?![a-z])', molecule)) * coefficient
47
- return carbon_count
48
- else:
49
- # NOTE: molecule has no carbon atoms
50
- return 0.0
51
- except Exception as e:
52
- raise Exception(
53
- f"Error counting carbon in molecule '{molecule}': {e}")
54
-
55
- def phase_rule_analysis(self, phase_rule: Optional[str] = None) -> str:
56
- """
57
- Analyze the phase rule of a reaction.
58
- """
59
- try:
60
- # SECTION: default phase rule
61
- # NOTE: empty means component states must be present in the reaction
62
- if phase_rule is None or phase_rule == 'None':
63
- return 'empty'
64
-
65
- # SECTION: validate phase rule
66
- # ? keep this aligned with PhaseRule in chem_react.py
67
- if phase_rule not in self.available_phases:
68
- raise ValueError(
69
- f"Phase rule must be {', '.join(self.available_phases)}.")
70
-
71
- # SECTION: convert full phase name to reaction state symbol
72
- if phase_rule == 'gas':
73
- phase_symbol = 'g'
74
- elif phase_rule == 'liquid':
75
- phase_symbol = 'l'
76
- elif phase_rule == 'aqueous':
77
- phase_symbol = 'aq'
78
- elif phase_rule == 'solid':
79
- phase_symbol = 's'
80
- else:
81
- phase_symbol = 'empty'
82
-
83
- # NOTE: return compact state symbol used by parsed components
84
- return phase_symbol
85
- except Exception as e:
86
- raise Exception(f"Error analyzing phase rule: {e}")
87
-
88
- def state_name_set(self, state_set: set) -> List[str]:
89
- """
90
- Convert state set to full names.
91
- """
92
- try:
93
- # SECTION: state name mapping
94
- # NOTE: keys match state symbols parsed from reaction strings
95
- state_dict = {
96
- 'g': 'gas',
97
- 'l': 'liquid',
98
- 'aq': 'aqueous',
99
- 's': 'solid'
100
- }
101
-
102
- # NOTE: convert each compact symbol to its full phase name
103
- return [state_dict[state] for state in state_set]
104
- except Exception as e:
105
- raise Exception(f"Error converting state set to full names: {e}")
106
-
107
- def determine_reaction_phase(self, reaction_dict: Dict[str, str]) -> str:
108
- """
109
- Determine the phase of a reaction based on component states.
110
- """
111
- try:
112
- # SECTION: collect unique states
113
- available_states = set(reaction_dict.values())
114
- # NOTE: convert state symbols before formatting phase text
115
- state_names = self.state_name_set(available_states)
116
-
117
- # SECTION: determine reaction phase label
118
- if len(state_names) == 1:
119
- # NOTE: single-phase reaction
120
- return f'{state_names[0]}'
121
- else:
122
- # NOTE: multi-phase reaction
123
- return f'{"-".join(state_names)}'
124
- except Exception as e:
125
- raise Exception(f"Error determining reaction phase: {e}")
126
-
127
- def count_reaction_states(self, reaction_dict: Dict[str, str]) -> Dict[str, int]:
128
- """
129
- Count the number of component states in a reaction.
130
- """
131
- try:
132
- # SECTION: collect component states
133
- available_states = reaction_dict.values()
134
- # NOTE: initialize all supported state buckets
135
- state_count = {
136
- 'g': 0,
137
- 'l': 0,
138
- 'aq': 0,
139
- 's': 0
140
- }
141
-
142
- # SECTION: count state occurrences
143
- for state in available_states:
144
- # ! ignore unsupported states instead of adding new keys
145
- if state in state_count:
146
- state_count[state] += 1
147
-
148
- # NOTE: return counts for every supported state symbol
149
- return state_count
150
- except Exception as e:
151
- raise Exception(f"Error determining reaction phase: {e}")
152
-
153
- # ! reaction types
154
-
155
- def get_reaction_type(self, reaction_mode_symbol: str) -> str:
156
- """
157
- Determine the type of reaction based on the reaction mode symbol.
158
-
159
- Reaction Mode Symbols:
160
- - `Reversible`: "<=>"
161
- - `Irreversible`: "=>"
162
- - `Equilibrium`: "="
163
- """
164
- try:
165
- # SECTION: validate reaction mode symbol
166
- if reaction_mode_symbol not in ("<=>", "=>", "="):
167
- raise ValueError(
168
- f"Invalid reaction mode symbol: {reaction_mode_symbol}")
169
-
170
- # SECTION: determine reaction type
171
- if reaction_mode_symbol == "<=>":
172
- return "reversible"
173
- elif reaction_mode_symbol == "=>":
174
- return "irreversible"
175
- elif reaction_mode_symbol == "=":
176
- return "equilibrium"
177
- else:
178
- raise ValueError(
179
- f"Unknown reaction mode symbol: {reaction_mode_symbol}")
180
- except Exception as e:
181
- raise Exception(f"Error determining reaction type: {e}")
File without changes