pyreactlab-core 0.1.8__tar.gz → 0.1.9__tar.gz

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