pychnosz 1.1.11__cp312-cp312-win_amd64.whl

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 (128) hide show
  1. pychnosz/__init__.py +129 -0
  2. pychnosz/biomolecules/__init__.py +29 -0
  3. pychnosz/biomolecules/ionize_aa.py +197 -0
  4. pychnosz/biomolecules/proteins.py +595 -0
  5. pychnosz/core/__init__.py +46 -0
  6. pychnosz/core/affinity.py +1256 -0
  7. pychnosz/core/animation.py +593 -0
  8. pychnosz/core/balance.py +334 -0
  9. pychnosz/core/basis.py +716 -0
  10. pychnosz/core/diagram.py +3336 -0
  11. pychnosz/core/equilibrate.py +813 -0
  12. pychnosz/core/equilibrium.py +554 -0
  13. pychnosz/core/info.py +821 -0
  14. pychnosz/core/retrieve.py +364 -0
  15. pychnosz/core/speciation.py +580 -0
  16. pychnosz/core/species.py +599 -0
  17. pychnosz/core/subcrt.py +1696 -0
  18. pychnosz/core/thermo.py +593 -0
  19. pychnosz/core/unicurve.py +1226 -0
  20. pychnosz/data/__init__.py +11 -0
  21. pychnosz/data/add_obigt.py +327 -0
  22. pychnosz/data/extdata/Berman/BDat17_2017.csv +2 -0
  23. pychnosz/data/extdata/Berman/Ber88_1988.csv +68 -0
  24. pychnosz/data/extdata/Berman/Ber90_1990.csv +5 -0
  25. pychnosz/data/extdata/Berman/DS10_2010.csv +6 -0
  26. pychnosz/data/extdata/Berman/FDM+14_2014.csv +2 -0
  27. pychnosz/data/extdata/Berman/Got04_2004.csv +5 -0
  28. pychnosz/data/extdata/Berman/JUN92_1992.csv +3 -0
  29. pychnosz/data/extdata/Berman/SHD91_1991.csv +12 -0
  30. pychnosz/data/extdata/Berman/VGT92_1992.csv +2 -0
  31. pychnosz/data/extdata/Berman/VPT01_2001.csv +3 -0
  32. pychnosz/data/extdata/Berman/VPV05_2005.csv +2 -0
  33. pychnosz/data/extdata/Berman/ZS92_1992.csv +11 -0
  34. pychnosz/data/extdata/Berman/sympy.R +99 -0
  35. pychnosz/data/extdata/Berman/testing/BA96.bib +12 -0
  36. pychnosz/data/extdata/Berman/testing/BA96_Berman.csv +21 -0
  37. pychnosz/data/extdata/Berman/testing/BA96_OBIGT.csv +21 -0
  38. pychnosz/data/extdata/Berman/testing/BA96_refs.csv +6 -0
  39. pychnosz/data/extdata/OBIGT/AD.csv +25 -0
  40. pychnosz/data/extdata/OBIGT/Berman_cr.csv +93 -0
  41. pychnosz/data/extdata/OBIGT/DEW.csv +211 -0
  42. pychnosz/data/extdata/OBIGT/H2O_aq.csv +4 -0
  43. pychnosz/data/extdata/OBIGT/SLOP98.csv +411 -0
  44. pychnosz/data/extdata/OBIGT/SUPCRT92.csv +178 -0
  45. pychnosz/data/extdata/OBIGT/inorganic_aq.csv +729 -0
  46. pychnosz/data/extdata/OBIGT/inorganic_cr.csv +273 -0
  47. pychnosz/data/extdata/OBIGT/inorganic_gas.csv +20 -0
  48. pychnosz/data/extdata/OBIGT/organic_aq.csv +1104 -0
  49. pychnosz/data/extdata/OBIGT/organic_cr.csv +481 -0
  50. pychnosz/data/extdata/OBIGT/organic_gas.csv +268 -0
  51. pychnosz/data/extdata/OBIGT/organic_liq.csv +533 -0
  52. pychnosz/data/extdata/OBIGT/testing/GEMSFIT.csv +43 -0
  53. pychnosz/data/extdata/OBIGT/testing/IGEM.csv +17 -0
  54. pychnosz/data/extdata/OBIGT/testing/Sandia.csv +8 -0
  55. pychnosz/data/extdata/OBIGT/testing/SiO2.csv +4 -0
  56. pychnosz/data/extdata/misc/AD03_Fig1a.csv +69 -0
  57. pychnosz/data/extdata/misc/AD03_Fig1b.csv +43 -0
  58. pychnosz/data/extdata/misc/AD03_Fig1c.csv +89 -0
  59. pychnosz/data/extdata/misc/AD03_Fig1d.csv +30 -0
  60. pychnosz/data/extdata/misc/BZA10.csv +5 -0
  61. pychnosz/data/extdata/misc/HW97_Cp.csv +90 -0
  62. pychnosz/data/extdata/misc/HWM96_V.csv +229 -0
  63. pychnosz/data/extdata/misc/LA19_test.csv +7 -0
  64. pychnosz/data/extdata/misc/Mer75_Table4.csv +42 -0
  65. pychnosz/data/extdata/misc/OBIGT_check.csv +423 -0
  66. pychnosz/data/extdata/misc/PM90.csv +7 -0
  67. pychnosz/data/extdata/misc/RH95.csv +23 -0
  68. pychnosz/data/extdata/misc/RH98_Table15.csv +17 -0
  69. pychnosz/data/extdata/misc/SC10_Rainbow.csv +19 -0
  70. pychnosz/data/extdata/misc/SK95.csv +55 -0
  71. pychnosz/data/extdata/misc/SOJSH.csv +61 -0
  72. pychnosz/data/extdata/misc/SS98_Fig5a.csv +81 -0
  73. pychnosz/data/extdata/misc/SS98_Fig5b.csv +84 -0
  74. pychnosz/data/extdata/misc/TKSS14_Fig2.csv +25 -0
  75. pychnosz/data/extdata/misc/bluered.txt +1000 -0
  76. pychnosz/data/extdata/protein/Cas/Cas_aa.csv +177 -0
  77. pychnosz/data/extdata/protein/Cas/Cas_uniprot.csv +186 -0
  78. pychnosz/data/extdata/protein/Cas/download.R +34 -0
  79. pychnosz/data/extdata/protein/Cas/mkaa.R +34 -0
  80. pychnosz/data/extdata/protein/POLG.csv +12 -0
  81. pychnosz/data/extdata/protein/TBD+05.csv +393 -0
  82. pychnosz/data/extdata/protein/TBD+05_aa.csv +393 -0
  83. pychnosz/data/extdata/protein/rubisco.csv +28 -0
  84. pychnosz/data/extdata/protein/rubisco.fasta +239 -0
  85. pychnosz/data/extdata/protein/rubisco_aa.csv +28 -0
  86. pychnosz/data/extdata/src/H2O92D.f.orig +3457 -0
  87. pychnosz/data/extdata/src/README.txt +5 -0
  88. pychnosz/data/extdata/taxonomy/names.dmp +215 -0
  89. pychnosz/data/extdata/taxonomy/nodes.dmp +63 -0
  90. pychnosz/data/extdata/thermo/Bdot_acirc.csv +60 -0
  91. pychnosz/data/extdata/thermo/buffer.csv +40 -0
  92. pychnosz/data/extdata/thermo/element.csv +135 -0
  93. pychnosz/data/extdata/thermo/groups.csv +6 -0
  94. pychnosz/data/extdata/thermo/opt.csv +2 -0
  95. pychnosz/data/extdata/thermo/protein.csv +506 -0
  96. pychnosz/data/extdata/thermo/refs.csv +343 -0
  97. pychnosz/data/extdata/thermo/stoich.csv.xz +0 -0
  98. pychnosz/data/loader.py +431 -0
  99. pychnosz/data/mod_obigt.py +322 -0
  100. pychnosz/data/obigt.py +471 -0
  101. pychnosz/data/worm.py +228 -0
  102. pychnosz/fortran/__init__.py +16 -0
  103. pychnosz/fortran/h2o92.dll +0 -0
  104. pychnosz/fortran/h2o92_interface.py +527 -0
  105. pychnosz/geochemistry/__init__.py +21 -0
  106. pychnosz/geochemistry/minerals.py +514 -0
  107. pychnosz/geochemistry/redox.py +500 -0
  108. pychnosz/models/__init__.py +47 -0
  109. pychnosz/models/archer_wang.py +165 -0
  110. pychnosz/models/berman.py +309 -0
  111. pychnosz/models/cgl.py +381 -0
  112. pychnosz/models/dew.py +997 -0
  113. pychnosz/models/hkf.py +523 -0
  114. pychnosz/models/hkf_helpers.py +231 -0
  115. pychnosz/models/iapws95.py +1113 -0
  116. pychnosz/models/supcrt92_fortran.py +238 -0
  117. pychnosz/models/water.py +480 -0
  118. pychnosz/utils/__init__.py +27 -0
  119. pychnosz/utils/expression.py +1074 -0
  120. pychnosz/utils/formula.py +830 -0
  121. pychnosz/utils/formula_ox.py +227 -0
  122. pychnosz/utils/reset.py +33 -0
  123. pychnosz/utils/units.py +259 -0
  124. pychnosz-1.1.11.dist-info/METADATA +197 -0
  125. pychnosz-1.1.11.dist-info/RECORD +128 -0
  126. pychnosz-1.1.11.dist-info/WHEEL +5 -0
  127. pychnosz-1.1.11.dist-info/licenses/LICENSE.txt +19 -0
  128. pychnosz-1.1.11.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1226 @@
1
+ """
2
+ CHNOSZ unicurve() function - Calculate univariant curves for geothermometry/geobarometry.
3
+
4
+ This module implements functions to solve for temperatures or pressures of equilibration
5
+ for a given logK value, producing univariant curves useful for aqueous geothermometry
6
+ and geobarometry applications.
7
+
8
+ Author: Based on pyCHNOSZ univariant.r by Grayson Boyer
9
+ Optimized: Uses scipy.optimize.brentq() for efficient root-finding
10
+ """
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from typing import Union, List, Optional, Dict, Any
15
+ import warnings
16
+ from scipy.optimize import brentq
17
+ from concurrent.futures import ProcessPoolExecutor, as_completed
18
+ import multiprocessing
19
+
20
+ from .subcrt import subcrt, SubcrtResult
21
+
22
+ # Import plotly for univariant_TP
23
+ try:
24
+ import plotly.graph_objects as go
25
+ PLOTLY_AVAILABLE = True
26
+ except ImportError:
27
+ PLOTLY_AVAILABLE = False
28
+ go = None
29
+
30
+
31
+ class UnivariantResult:
32
+ """Result structure for univariant curve calculations."""
33
+
34
+ def __init__(self):
35
+ self.reaction = None # Reaction summary DataFrame
36
+ self.out = None # Results DataFrame with T/P and thermodynamic properties
37
+ self.warnings = [] # Warning messages
38
+ self.fig = None # Plotly figure object (if plot_it=True)
39
+
40
+ def __repr__(self):
41
+ if self.out is not None:
42
+ return f"UnivariantResult with {len(self.out)} points"
43
+ return "UnivariantResult (no calculations performed)"
44
+
45
+ def __getitem__(self, key):
46
+ """Allow dictionary-style access to attributes."""
47
+ return getattr(self, key)
48
+
49
+
50
+ def _solve_T_for_pressure(logK: float, species: List, state: List, coeff: List,
51
+ pressure: float, IS: float, minT: float, maxT: float,
52
+ tol: float, initial_guess: Optional[float] = None,
53
+ messages: bool = False) -> Dict[str, Any]:
54
+ """
55
+ Solve for temperature at a given pressure that produces the target logK.
56
+
57
+ Uses scipy.optimize.brentq (Brent's method) for efficient root-finding.
58
+ Brent's method combines bisection, secant, and inverse quadratic interpolation
59
+ for guaranteed convergence with minimal function evaluations.
60
+
61
+ Parameters
62
+ ----------
63
+ logK : float
64
+ Target logarithm (base 10) of equilibrium constant
65
+ species : list
66
+ List of species names or indices
67
+ state : list
68
+ List of states for each species
69
+ coeff : list
70
+ Reaction coefficients
71
+ pressure : float
72
+ Pressure in bars
73
+ IS : float
74
+ Ionic strength
75
+ minT : float
76
+ Minimum temperature (°C) to search
77
+ maxT : float
78
+ Maximum temperature (°C) to search
79
+ tol : float
80
+ Tolerance for convergence
81
+ initial_guess : float, optional
82
+ Initial guess for warm start (not used by brentq but kept for future optimization)
83
+ messages : bool
84
+ Print messages
85
+
86
+ Returns
87
+ -------
88
+ dict
89
+ Dictionary with 'T', 'P', 'logK', and other thermodynamic properties,
90
+ or None values if no solution found
91
+ """
92
+
93
+ def objective(T):
94
+ """Objective function: returns (calculated_logK - target_logK)."""
95
+ try:
96
+ result = subcrt(species, coeff=coeff, state=state,
97
+ T=T, P=pressure, IS=IS,
98
+ exceed_Ttr=True, messages=False, show=False)
99
+
100
+ if result.out is None or 'logK' not in result.out.columns:
101
+ return np.nan
102
+
103
+ calc_logK = result.out['logK'].iloc[0]
104
+
105
+ if pd.isna(calc_logK) or not np.isfinite(calc_logK):
106
+ return np.nan
107
+
108
+ return calc_logK - logK
109
+
110
+ except Exception:
111
+ return np.nan
112
+
113
+ # Check if root is bracketed by evaluating at endpoints
114
+ try:
115
+ f_min = objective(minT)
116
+ f_max = objective(maxT)
117
+
118
+ # If boundaries return NaN, search inward to find valid endpoints
119
+ current_minT = minT
120
+ current_maxT = maxT
121
+
122
+ if np.isnan(f_min):
123
+ # Search from minT upward to find a valid lower bound
124
+ step = (maxT - minT) / 20 # Use 20 steps to search
125
+ for i in range(1, 20):
126
+ test_T = minT + i * step
127
+ f_test = objective(test_T)
128
+ if not np.isnan(f_test):
129
+ current_minT = test_T
130
+ f_min = f_test
131
+ if messages:
132
+ print(f" Adjusted minT from {minT:.1f} to {current_minT:.1f}°C (valid boundary)")
133
+ break
134
+ else:
135
+ # Could not find valid lower bound
136
+ if messages:
137
+ print(f"Could not find valid lower temperature bound for P={pressure} bar")
138
+ return {
139
+ 'T': None, 'P': pressure, 'logK': None, 'G': None,
140
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
141
+ 'Warning': f"Could not converge on T for this P within {minT} and {maxT} degC"
142
+ }
143
+
144
+ if np.isnan(f_max):
145
+ # Search from maxT downward to find a valid upper bound
146
+ step = (maxT - minT) / 20 # Use 20 steps to search
147
+ for i in range(1, 20):
148
+ test_T = maxT - i * step
149
+ f_test = objective(test_T)
150
+ if not np.isnan(f_test):
151
+ current_maxT = test_T
152
+ f_max = f_test
153
+ if messages:
154
+ print(f" Adjusted maxT from {maxT:.1f} to {current_maxT:.1f}°C (valid boundary)")
155
+ break
156
+ else:
157
+ # Could not find valid upper bound
158
+ if messages:
159
+ print(f"Could not find valid upper temperature bound for P={pressure} bar")
160
+ return {
161
+ 'T': None, 'P': pressure, 'logK': None, 'G': None,
162
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
163
+ 'Warning': f"Could not converge on T for this P within {minT} and {maxT} degC"
164
+ }
165
+
166
+ # Check if root is bracketed (signs must be opposite)
167
+ if f_min * f_max > 0:
168
+ if messages:
169
+ print(f"Root not bracketed at P={pressure} bar: logK range [{f_min+logK:.3f}, {f_max+logK:.3f}] doesn't include target {logK:.3f}")
170
+ return {
171
+ 'T': None, 'P': pressure, 'logK': None, 'G': None,
172
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
173
+ 'Warning': f"Could not converge on T for this P within {minT} and {maxT} degC"
174
+ }
175
+
176
+ # Use Brent's method to find the root
177
+ T_solution = brentq(objective, current_minT, current_maxT, xtol=tol, rtol=tol)
178
+
179
+ # Get full thermodynamic properties at the solution
180
+ final_result = subcrt(species, coeff=coeff, state=state,
181
+ T=T_solution, P=pressure, IS=IS,
182
+ exceed_Ttr=True, messages=False, show=False)
183
+
184
+ result_dict = {
185
+ 'T': T_solution,
186
+ 'P': pressure,
187
+ 'logK': final_result.out['logK'].iloc[0] if 'logK' in final_result.out.columns else None,
188
+ 'G': final_result.out['G'].iloc[0] if 'G' in final_result.out.columns else None,
189
+ 'H': final_result.out['H'].iloc[0] if 'H' in final_result.out.columns else None,
190
+ 'S': final_result.out['S'].iloc[0] if 'S' in final_result.out.columns else None,
191
+ 'V': final_result.out['V'].iloc[0] if 'V' in final_result.out.columns else None,
192
+ 'Cp': final_result.out['Cp'].iloc[0] if 'Cp' in final_result.out.columns else None,
193
+ }
194
+
195
+ if 'rho' in final_result.out.columns:
196
+ result_dict['rho'] = final_result.out['rho'].iloc[0]
197
+ else:
198
+ result_dict['rho'] = None
199
+
200
+ return result_dict
201
+
202
+ except ValueError as e:
203
+ if messages:
204
+ warnings.warn(f"Brent's method failed at P={pressure} bar: {str(e)}")
205
+ return {
206
+ 'T': None, 'P': pressure, 'logK': None, 'G': None,
207
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
208
+ 'Warning': f"Could not converge on T for this P within {minT} and {maxT} degC"
209
+ }
210
+ except Exception as e:
211
+ if messages:
212
+ warnings.warn(f"Error during calculation at P={pressure} bar: {str(e)}")
213
+ return {
214
+ 'T': None, 'P': pressure, 'logK': None, 'G': None,
215
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
216
+ 'Warning': f"Could not converge on T for this P within {minT} and {maxT} degC"
217
+ }
218
+
219
+
220
+ def _create_unicurve_plot(logK: float, species: List, state: List, coeff: List,
221
+ result: UnivariantResult, solve: str,
222
+ minT: float, maxT: float, minP: float, maxP: float,
223
+ IS: float, width: int, height: int, res: int,
224
+ messages: bool = False):
225
+ """
226
+ Create interactive plotly plot for unicurve results.
227
+
228
+ Shows logK vs T (or P) curves with horizontal line at target logK
229
+ and marks intersection points (solutions).
230
+ """
231
+ if not PLOTLY_AVAILABLE:
232
+ return
233
+
234
+ # Plotly default color sequence
235
+ default_colors = [
236
+ '#636EFA', '#EF553B', '#00CC96', '#AB63FA', '#FFA15A',
237
+ '#19D3F3', '#FF6692', '#B6E880', '#FF97FF', '#FECB52'
238
+ ]
239
+
240
+ fig = go.Figure()
241
+
242
+ if solve.upper() == "T":
243
+ # Solving for T: plot logK vs T for each pressure
244
+ # Generate T range for plotting
245
+ T_range = np.linspace(minT, maxT, res)
246
+
247
+ # Get list of pressures from results
248
+ pressures = result.out['P'].dropna().unique()
249
+
250
+ # Plot logK curves for each pressure
251
+ for i, pressure in enumerate(pressures):
252
+ color = default_colors[i % len(default_colors)]
253
+ try:
254
+ # Calculate logK across T range at this pressure
255
+ calc_result = subcrt(species, coeff=coeff, state=state,
256
+ T=T_range, P=pressure, IS=IS,
257
+ exceed_Ttr=True, messages=False, show=False)
258
+
259
+ if calc_result.out is not None and 'logK' in calc_result.out.columns:
260
+ # Plot the logK curve
261
+ fig.add_trace(go.Scatter(
262
+ x=calc_result.out['T'],
263
+ y=calc_result.out['logK'],
264
+ mode='lines',
265
+ name=f'P = {pressure:.0f} bar',
266
+ line=dict(width=2, color=color),
267
+ hovertemplate='P=%{text} bar<br>T=%{x:.2f}°C<br>logK=%{y:.6f}<extra></extra>',
268
+ text=[f'{pressure:.0f}' for _ in range(len(calc_result.out))]
269
+ ))
270
+
271
+ # Mark the solution point on this curve (same color as curve)
272
+ solution_T = result.out.loc[result.out['P'] == pressure, 'T'].values
273
+ if len(solution_T) > 0 and pd.notna(solution_T[0]):
274
+ fig.add_trace(go.Scatter(
275
+ x=[solution_T[0]],
276
+ y=[logK],
277
+ mode='markers',
278
+ name=f'Solution (T={solution_T[0]:.1f}°C)',
279
+ marker=dict(size=10, symbol='circle', color=color, line=dict(width=2, color='white')),
280
+ hovertemplate=f'Solution<br>P={pressure:.0f} bar<br>T=%{{x:.2f}}°C<br>logK={logK:.6f}<extra></extra>'
281
+ ))
282
+
283
+ except Exception as e:
284
+ if messages:
285
+ warnings.warn(f"Could not plot curve for P={pressure} bar: {str(e)}")
286
+
287
+ # Add horizontal line at target logK
288
+ fig.add_trace(go.Scatter(
289
+ x=[minT, maxT],
290
+ y=[logK, logK],
291
+ mode='lines',
292
+ name=f'Target logK = {logK}',
293
+ line=dict(color='red', width=2, dash='dash'),
294
+ hovertemplate=f'Target logK={logK:.6f}<extra></extra>'
295
+ ))
296
+
297
+ # Update layout
298
+ fig.update_layout(
299
+ template="simple_white",
300
+ title="Univariant Curve: logK vs Temperature",
301
+ xaxis_title="Temperature (°C)",
302
+ yaxis_title="logK",
303
+ width=width,
304
+ height=height,
305
+ hoverlabel=dict(bgcolor="white"),
306
+ showlegend=True
307
+ )
308
+
309
+ elif solve.upper() == "P":
310
+ # Solving for P: plot logK vs P for each temperature
311
+ # Generate P range for plotting
312
+ P_range = np.linspace(minP, maxP, res)
313
+
314
+ # Get list of temperatures from results
315
+ temperatures = result.out['T'].dropna().unique()
316
+
317
+ # Plot logK curves for each temperature
318
+ for i, temperature in enumerate(temperatures):
319
+ color = default_colors[i % len(default_colors)]
320
+ try:
321
+ # Calculate logK across P range at this temperature
322
+ calc_result = subcrt(species, coeff=coeff, state=state,
323
+ T=temperature, P=P_range, IS=IS,
324
+ exceed_Ttr=True, messages=False, show=False)
325
+
326
+ if calc_result.out is not None and 'logK' in calc_result.out.columns:
327
+ # Plot the logK curve
328
+ fig.add_trace(go.Scatter(
329
+ x=calc_result.out['P'],
330
+ y=calc_result.out['logK'],
331
+ mode='lines',
332
+ name=f'T = {temperature:.0f}°C',
333
+ line=dict(width=2, color=color),
334
+ hovertemplate='T=%{text}°C<br>P=%{x:.2f} bar<br>logK=%{y:.6f}<extra></extra>',
335
+ text=[f'{temperature:.0f}' for _ in range(len(calc_result.out))]
336
+ ))
337
+
338
+ # Mark the solution point on this curve (same color as curve)
339
+ solution_P = result.out.loc[result.out['T'] == temperature, 'P'].values
340
+ if len(solution_P) > 0 and pd.notna(solution_P[0]):
341
+ fig.add_trace(go.Scatter(
342
+ x=[solution_P[0]],
343
+ y=[logK],
344
+ mode='markers',
345
+ name=f'Solution (P={solution_P[0]:.1f} bar)',
346
+ marker=dict(size=10, symbol='circle', color=color, line=dict(width=2, color='white')),
347
+ hovertemplate=f'Solution<br>T={temperature:.0f}°C<br>P=%{{x:.2f}} bar<br>logK={logK:.6f}<extra></extra>'
348
+ ))
349
+
350
+ except Exception as e:
351
+ if messages:
352
+ warnings.warn(f"Could not plot curve for T={temperature}°C: {str(e)}")
353
+
354
+ # Add horizontal line at target logK
355
+ fig.add_trace(go.Scatter(
356
+ x=[minP, maxP],
357
+ y=[logK, logK],
358
+ mode='lines',
359
+ name=f'Target logK = {logK}',
360
+ line=dict(color='red', width=2, dash='dash'),
361
+ hovertemplate=f'Target logK={logK:.6f}<extra></extra>'
362
+ ))
363
+
364
+ # Update layout
365
+ fig.update_layout(
366
+ template="simple_white",
367
+ title="Univariant Curve: logK vs Pressure",
368
+ xaxis_title="Pressure (bar)",
369
+ yaxis_title="logK",
370
+ width=width,
371
+ height=height,
372
+ hoverlabel=dict(bgcolor="white"),
373
+ showlegend=True
374
+ )
375
+
376
+ # Configure plot controls
377
+ config = {
378
+ 'displaylogo': False,
379
+ 'modeBarButtonsToRemove': ['resetScale2d', 'toggleSpikelines'],
380
+ }
381
+
382
+ # Display plot
383
+ fig.show(config=config)
384
+
385
+ # Return figure for storage in result
386
+ return fig
387
+
388
+
389
+ def _solve_P_for_temperature(logK: float, species: List, state: List, coeff: List,
390
+ temperature: float, IS: float, minP: float, maxP: float,
391
+ tol: float, initial_guess: Optional[float] = None,
392
+ messages: bool = False) -> Dict[str, Any]:
393
+ """
394
+ Solve for pressure at a given temperature that produces the target logK.
395
+
396
+ Uses scipy.optimize.brentq (Brent's method) for efficient root-finding.
397
+
398
+ Parameters
399
+ ----------
400
+ logK : float
401
+ Target logarithm (base 10) of equilibrium constant
402
+ species : list
403
+ List of species names or indices
404
+ state : list
405
+ List of states for each species
406
+ coeff : list
407
+ Reaction coefficients
408
+ temperature : float
409
+ Temperature in °C
410
+ IS : float
411
+ Ionic strength
412
+ minP : float
413
+ Minimum pressure (bar) to search
414
+ maxP : float
415
+ Maximum pressure (bar) to search
416
+ tol : float
417
+ Tolerance for convergence
418
+ initial_guess : float, optional
419
+ Initial guess for warm start (not used by brentq but kept for future optimization)
420
+ messages : bool
421
+ Print messages
422
+
423
+ Returns
424
+ -------
425
+ dict
426
+ Dictionary with 'T', 'P', 'logK', and other thermodynamic properties,
427
+ or None values if no solution found
428
+ """
429
+
430
+ def objective(P):
431
+ """Objective function: returns (calculated_logK - target_logK)."""
432
+ try:
433
+ result = subcrt(species, coeff=coeff, state=state,
434
+ T=temperature, P=P, IS=IS,
435
+ exceed_Ttr=True, messages=False, show=False)
436
+
437
+ if result.out is None or 'logK' not in result.out.columns:
438
+ return np.nan
439
+
440
+ calc_logK = result.out['logK'].iloc[0]
441
+
442
+ if pd.isna(calc_logK) or not np.isfinite(calc_logK):
443
+ return np.nan
444
+
445
+ return calc_logK - logK
446
+
447
+ except Exception:
448
+ return np.nan
449
+
450
+ # Check if root is bracketed by evaluating at endpoints
451
+ try:
452
+ f_min = objective(minP)
453
+ f_max = objective(maxP)
454
+
455
+ # If boundaries return NaN, search inward to find valid endpoints
456
+ current_minP = minP
457
+ current_maxP = maxP
458
+
459
+ if np.isnan(f_min):
460
+ # Search from minP upward to find a valid lower bound
461
+ step = (maxP - minP) / 20 # Use 20 steps to search
462
+ for i in range(1, 20):
463
+ test_P = minP + i * step
464
+ f_test = objective(test_P)
465
+ if not np.isnan(f_test):
466
+ current_minP = test_P
467
+ f_min = f_test
468
+ if messages:
469
+ print(f" Adjusted minP from {minP:.1f} to {current_minP:.1f} bar (valid boundary)")
470
+ break
471
+ else:
472
+ # Could not find valid lower bound
473
+ if messages:
474
+ print(f"Could not find valid lower pressure bound for T={temperature}°C")
475
+ return {
476
+ 'T': temperature, 'P': None, 'logK': None, 'G': None,
477
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
478
+ 'Warning': f"Could not converge on P for this T within {minP} and {maxP} bar(s)"
479
+ }
480
+
481
+ if np.isnan(f_max):
482
+ # Search from maxP downward to find a valid upper bound
483
+ step = (maxP - minP) / 20 # Use 20 steps to search
484
+ for i in range(1, 20):
485
+ test_P = maxP - i * step
486
+ f_test = objective(test_P)
487
+ if not np.isnan(f_test):
488
+ current_maxP = test_P
489
+ f_max = f_test
490
+ if messages:
491
+ print(f" Adjusted maxP from {maxP:.1f} to {current_maxP:.1f} bar (valid boundary)")
492
+ break
493
+ else:
494
+ # Could not find valid upper bound
495
+ if messages:
496
+ print(f"Could not find valid upper pressure bound for T={temperature}°C")
497
+ return {
498
+ 'T': temperature, 'P': None, 'logK': None, 'G': None,
499
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
500
+ 'Warning': f"Could not converge on P for this T within {minP} and {maxP} bar(s)"
501
+ }
502
+
503
+ # Check if root is bracketed (signs must be opposite)
504
+ if f_min * f_max > 0:
505
+ if messages:
506
+ print(f"Root not bracketed at T={temperature}°C: logK range [{f_min+logK:.3f}, {f_max+logK:.3f}] doesn't include target {logK:.3f}")
507
+ return {
508
+ 'T': temperature, 'P': None, 'logK': None, 'G': None,
509
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
510
+ 'Warning': f"Could not converge on P for this T within {minP} and {maxP} bar(s)"
511
+ }
512
+
513
+ # Use Brent's method to find the root
514
+ P_solution = brentq(objective, current_minP, current_maxP, xtol=tol, rtol=tol)
515
+
516
+ # Get full thermodynamic properties at the solution
517
+ final_result = subcrt(species, coeff=coeff, state=state,
518
+ T=temperature, P=P_solution, IS=IS,
519
+ exceed_Ttr=True, messages=False, show=False)
520
+
521
+ result_dict = {
522
+ 'T': temperature,
523
+ 'P': P_solution,
524
+ 'logK': final_result.out['logK'].iloc[0] if 'logK' in final_result.out.columns else None,
525
+ 'G': final_result.out['G'].iloc[0] if 'G' in final_result.out.columns else None,
526
+ 'H': final_result.out['H'].iloc[0] if 'H' in final_result.out.columns else None,
527
+ 'S': final_result.out['S'].iloc[0] if 'S' in final_result.out.columns else None,
528
+ 'V': final_result.out['V'].iloc[0] if 'V' in final_result.out.columns else None,
529
+ 'Cp': final_result.out['Cp'].iloc[0] if 'Cp' in final_result.out.columns else None,
530
+ }
531
+
532
+ if 'rho' in final_result.out.columns:
533
+ result_dict['rho'] = final_result.out['rho'].iloc[0]
534
+ else:
535
+ result_dict['rho'] = None
536
+
537
+ return result_dict
538
+
539
+ except ValueError as e:
540
+ if messages:
541
+ warnings.warn(f"Brent's method failed at T={temperature}°C: {str(e)}")
542
+ return {
543
+ 'T': temperature, 'P': None, 'logK': None, 'G': None,
544
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
545
+ 'Warning': f"Could not converge on P for this T within {minP} and {maxP} bar(s)"
546
+ }
547
+ except Exception as e:
548
+ if messages:
549
+ warnings.warn(f"Error during calculation at T={temperature}°C: {str(e)}")
550
+ return {
551
+ 'T': temperature, 'P': None, 'logK': None, 'G': None,
552
+ 'H': None, 'S': None, 'V': None, 'Cp': None, 'rho': None,
553
+ 'Warning': f"Could not converge on P for this T within {minP} and {maxP} bar(s)"
554
+ }
555
+
556
+
557
+ def unicurve(logK: Union[float, int, List[Union[float, int]]],
558
+ species: Union[str, List[str], int, List[int]],
559
+ coeff: Union[int, float, List[Union[int, float]]],
560
+ state: Union[str, List[str]],
561
+ pressures: Union[float, List[float]] = 1,
562
+ temperatures: Union[float, List[float]] = 25,
563
+ IS: float = 0,
564
+ minT: float = 0.1,
565
+ maxT: float = 100,
566
+ minP: float = 1,
567
+ maxP: float = 500,
568
+ tol: Optional[float] = None,
569
+ solve: str = "T",
570
+ messages: bool = True,
571
+ show: bool = True,
572
+ plot_it: bool = True,
573
+ width: int = 600,
574
+ height: int = 400,
575
+ res: int = 200) -> Union[UnivariantResult, List[UnivariantResult]]:
576
+ """
577
+ Solve for temperatures or pressures of equilibration for a given logK value(s).
578
+
579
+ This function calculates univariant curves useful for aqueous geothermometry
580
+ and geobarometry. Given a measured equilibrium constant (logK) for a reaction,
581
+ it solves for the temperatures (at specified pressures) or pressures (at
582
+ specified temperatures) where the reaction would produce that logK value.
583
+
584
+ The solver uses scipy.optimize.brentq (Brent's method), which combines
585
+ bisection, secant, and inverse quadratic interpolation for efficient and
586
+ robust convergence. This is ~100x faster than the original binary search
587
+ algorithm while maintaining identical numerical accuracy.
588
+
589
+ Parameters
590
+ ----------
591
+ logK : float, int, or list of float or int
592
+ Logarithm (base 10) of the equilibrium constant(s). When a list is
593
+ provided, each logK value is processed separately and a list of results
594
+ is returned.
595
+ species : str, int, or list of str or int
596
+ Name, formula, or database index of species involved in the reaction
597
+ coeff : int, float, or list
598
+ Reaction stoichiometric coefficients (negative for reactants, positive for products)
599
+ state : str or list of str
600
+ Physical state(s) of species: "aq", "cr", "gas", "liq"
601
+ pressures : float or list of float, default 1
602
+ Pressure(s) in bars (used when solving for temperature)
603
+ temperatures : float or list of float, default 25
604
+ Temperature(s) in °C (used when solving for pressure)
605
+ IS : float, default 0
606
+ Ionic strength for activity corrections (mol/kg)
607
+ minT : float, default 0.1
608
+ Minimum temperature (°C) to search (ignored when solving for pressure)
609
+ maxT : float, default 100
610
+ Maximum temperature (°C) to search (ignored when solving for pressure)
611
+ minP : float, default 1
612
+ Minimum pressure (bar) to search (ignored when solving for temperature)
613
+ maxP : float, default 500
614
+ Maximum pressure (bar) to search (ignored when solving for temperature)
615
+ tol : float, optional
616
+ Tolerance for convergence. Default: 1/(10^(n+2)) where n is number of
617
+ decimal places in logK, with maximum default of 1e-5
618
+ solve : str, default "T"
619
+ What to solve for: "T" for temperature or "P" for pressure
620
+ messages : bool, default True
621
+ Print informational messages
622
+ show : bool, default True
623
+ Display result table
624
+ plot_it : bool, default True
625
+ Display interactive plotly plot showing logK vs T (or P) with target logK
626
+ as horizontal line and intersection points marked
627
+ width : int, default 600
628
+ Plot width in pixels (used if plot_it=True)
629
+ height : int, default 400
630
+ Plot height in pixels (used if plot_it=True)
631
+ res : int, default 200
632
+ Number of points to calculate for plotting the logK curve
633
+ (used if plot_it=True)
634
+
635
+ Returns
636
+ -------
637
+ UnivariantResult or list of UnivariantResult
638
+ When logK is a single value: returns a UnivariantResult object.
639
+ When logK is a list: returns a list of UnivariantResult objects.
640
+ Each result contains:
641
+ - reaction: DataFrame with reaction stoichiometry
642
+ - out: DataFrame with solved T or P values and thermodynamic properties
643
+ - warnings: List of warning messages
644
+
645
+ Examples
646
+ --------
647
+ >>> from pychnosz import unicurve, reset
648
+ >>> reset()
649
+ >>>
650
+ >>> # Solve for temperature: quartz dissolution
651
+ >>> # SiO2(quartz) = SiO2(aq)
652
+ >>> result = unicurve(logK=-2.71, species=["quartz", "SiO2"],
653
+ ... state=["cr", "aq"], coeff=[-1, 1],
654
+ ... pressures=200, minT=1, maxT=350)
655
+ >>> print(result.out[["P", "T", "logK"]])
656
+ >>>
657
+ >>> # Solve for pressure: water dissociation
658
+ >>> result = unicurve(logK=-14, species=["H2O", "H+", "OH-"],
659
+ ... state=["liq", "aq", "aq"], coeff=[-1, 1, 1],
660
+ ... temperatures=[25, 50, 75], solve="P",
661
+ ... minP=1, maxP=1000)
662
+ >>> print(result.out[["T", "P", "logK"]])
663
+
664
+ Notes
665
+ -----
666
+ This function uses scipy.optimize.brentq for root-finding, which provides:
667
+ - Guaranteed convergence if root is bracketed
668
+ - Typical convergence in 5-15 function evaluations
669
+ - ~100x speedup compared to custom binary search (1600 → 15 evaluations)
670
+ - Identical numerical results to original implementation
671
+
672
+ The algorithm also implements "warm start" optimization: when solving for
673
+ multiple pressures/temperatures, previous solutions are used to intelligently
674
+ bracket subsequent searches, further improving performance.
675
+
676
+ References
677
+ ----------
678
+ Based on univariant.r from pyCHNOSZ by Grayson Boyer
679
+ Optimized using Brent, R. P. (1973). Algorithms for Minimization without Derivatives.
680
+ """
681
+ # Track whether input was a single value or list
682
+ single_logK_input = not isinstance(logK, list)
683
+
684
+ # Ensure logK is a list for processing
685
+ if single_logK_input:
686
+ logK_list = [logK]
687
+ else:
688
+ logK_list = logK
689
+
690
+ # Ensure species, state, and coeff are lists
691
+ if not isinstance(species, list):
692
+ species = [species]
693
+ if not isinstance(state, list):
694
+ state = [state]
695
+ if not isinstance(coeff, list):
696
+ coeff = [coeff]
697
+
698
+ # Process each logK value
699
+ results = []
700
+
701
+ for this_logK in logK_list:
702
+ result = UnivariantResult()
703
+
704
+ # Set default tolerance based on logK precision
705
+ if tol is None:
706
+ # Count decimal places in logK
707
+ logK_str = str(float(this_logK))
708
+ if '.' in logK_str:
709
+ n_decimals = len(logK_str.split('.')[1].rstrip('0'))
710
+ else:
711
+ n_decimals = 0
712
+ this_tol = 10 ** (-(n_decimals + 2))
713
+ if this_tol > 1e-5:
714
+ this_tol = 1e-5
715
+ else:
716
+ this_tol = tol
717
+
718
+ # Get reaction information from first subcrt call
719
+ try:
720
+ initial_calc = subcrt(species, coeff=coeff, state=state, T=25, P=1,
721
+ exceed_Ttr=True, messages=False, show=False)
722
+ result.reaction = initial_calc.reaction
723
+ except Exception as e:
724
+ if messages:
725
+ warnings.warn(f"Error getting reaction information: {str(e)}")
726
+ result.reaction = None
727
+
728
+ if solve.upper() == "T":
729
+ # Solve for temperature at given pressure(s)
730
+ if not isinstance(pressures, list):
731
+ pressures = [pressures]
732
+
733
+ results_list = []
734
+ prev_T = None # For warm start optimization
735
+
736
+ for i, pressure in enumerate(pressures):
737
+ if messages:
738
+ print(f"Solving for T at P = {pressure} bar (logK = {this_logK})...")
739
+
740
+ # Warm start: use previous solution to narrow search range if available
741
+ current_minT = minT
742
+ current_maxT = maxT
743
+ if prev_T is not None and minT < prev_T < maxT:
744
+ # Center search around previous solution with a safety margin
745
+ # logK typically changes by ~0.006 per °C, so ±50°C should be safe
746
+ margin = 50
747
+ current_minT = max(minT, prev_T - margin)
748
+ current_maxT = min(maxT, prev_T + margin)
749
+ if messages:
750
+ print(f" Using warm start: searching {current_minT:.1f} to {current_maxT:.1f}°C")
751
+
752
+ result_dict = _solve_T_for_pressure(this_logK, species, state, coeff, pressure,
753
+ IS, current_minT, current_maxT, this_tol,
754
+ initial_guess=prev_T, messages=messages)
755
+
756
+ # If warm start failed, try full range
757
+ if result_dict['T'] is None and prev_T is not None:
758
+ if messages:
759
+ print(f" Warm start failed, searching full range...")
760
+ result_dict = _solve_T_for_pressure(this_logK, species, state, coeff, pressure,
761
+ IS, minT, maxT, this_tol, messages=messages)
762
+
763
+ results_list.append(result_dict)
764
+
765
+ # Update for next warm start
766
+ if result_dict['T'] is not None:
767
+ prev_T = result_dict['T']
768
+
769
+ result.out = pd.DataFrame(results_list)
770
+
771
+ elif solve.upper() == "P":
772
+ # Solve for pressure at given temperature(s)
773
+ if not isinstance(temperatures, list):
774
+ temperatures = [temperatures]
775
+
776
+ results_list = []
777
+ prev_P = None # For warm start optimization
778
+
779
+ for i, temperature in enumerate(temperatures):
780
+ if messages:
781
+ print(f"Solving for P at T = {temperature} °C (logK = {this_logK})...")
782
+
783
+ # Warm start: use previous solution to narrow search range if available
784
+ current_minP = minP
785
+ current_maxP = maxP
786
+ if prev_P is not None and minP < prev_P < maxP:
787
+ # Center search around previous solution with a safety margin
788
+ # Pressure effects vary, use a generous ±500 bar margin
789
+ margin = 500
790
+ current_minP = max(minP, prev_P - margin)
791
+ current_maxP = min(maxP, prev_P + margin)
792
+ if messages:
793
+ print(f" Using warm start: searching {current_minP:.0f} to {current_maxP:.0f} bar")
794
+
795
+ result_dict = _solve_P_for_temperature(this_logK, species, state, coeff, temperature,
796
+ IS, current_minP, current_maxP, this_tol,
797
+ initial_guess=prev_P, messages=messages)
798
+
799
+ # If warm start failed, try full range
800
+ if result_dict['P'] is None and prev_P is not None:
801
+ if messages:
802
+ print(f" Warm start failed, searching full range...")
803
+ result_dict = _solve_P_for_temperature(this_logK, species, state, coeff, temperature,
804
+ IS, minP, maxP, this_tol, messages=messages)
805
+
806
+ results_list.append(result_dict)
807
+
808
+ # Update for next warm start
809
+ if result_dict['P'] is not None:
810
+ prev_P = result_dict['P']
811
+
812
+ result.out = pd.DataFrame(results_list)
813
+
814
+ else:
815
+ raise ValueError(f"solve must be 'T' or 'P', got '{solve}'")
816
+
817
+ # Create interactive plot if requested
818
+ if plot_it:
819
+ if not PLOTLY_AVAILABLE:
820
+ warnings.warn("plotly is not installed. Set plot_it=False to suppress this warning, "
821
+ "or install plotly with: pip install plotly")
822
+ else:
823
+ result.fig = _create_unicurve_plot(this_logK, species, state, coeff, result, solve,
824
+ minT, maxT, minP, maxP, IS, width, height, res, messages)
825
+
826
+ # Display result if requested
827
+ if show and result.out is not None:
828
+ try:
829
+ from IPython.display import display
830
+ if result.reaction is not None:
831
+ print("\nReaction:")
832
+ display(result.reaction)
833
+ print(f"\nResults (logK = {this_logK}):")
834
+ display(result.out)
835
+ except ImportError:
836
+ # Not in Jupyter, just print
837
+ if result.reaction is not None:
838
+ print("\nReaction:")
839
+ print(result.reaction)
840
+ print(f"\nResults (logK = {this_logK}):")
841
+ print(result.out)
842
+
843
+ # Add this result to the list
844
+ results.append(result)
845
+
846
+ # Return single result or list based on input
847
+ if single_logK_input:
848
+ return results[0]
849
+ else:
850
+ return results
851
+
852
+
853
+ def _process_single_logK(args):
854
+ """
855
+ Helper function to process a single logK value for univariant_TP.
856
+
857
+ This function is designed to be called in parallel via multiprocessing.
858
+
859
+ Parameters
860
+ ----------
861
+ args : tuple
862
+ Tuple containing (this_logK, species, state, coeff, pressures, Trange, IS, tol, show, messages)
863
+
864
+ Returns
865
+ -------
866
+ UnivariantResult
867
+ Result for this logK value
868
+ """
869
+ this_logK, species, state, coeff, pressures, Trange, IS, tol, show, messages = args
870
+
871
+ # Set tolerance if not specified
872
+ if tol is None:
873
+ logK_str = str(float(this_logK))
874
+ if '.' in logK_str:
875
+ n_decimals = len(logK_str.split('.')[1].rstrip('0'))
876
+ else:
877
+ n_decimals = 0
878
+ this_tol = 10 ** (-(n_decimals + 2))
879
+ if this_tol > 1e-5:
880
+ this_tol = 1e-5
881
+ else:
882
+ this_tol = tol
883
+
884
+ # Solve for T at each pressure
885
+ out = unicurve(
886
+ solve="T",
887
+ logK=this_logK,
888
+ species=species,
889
+ state=state,
890
+ coeff=coeff,
891
+ pressures=list(pressures),
892
+ minT=Trange[0],
893
+ maxT=Trange[1],
894
+ IS=IS,
895
+ tol=this_tol,
896
+ show=show,
897
+ messages=messages,
898
+ plot_it=False # Don't plot individual curves
899
+ )
900
+
901
+ return out
902
+
903
+
904
+ def univariant_TP(logK: Union[float, int, List[Union[float, int]]],
905
+ species: Union[str, List[str], int, List[int]],
906
+ coeff: Union[int, float, List[Union[int, float]]],
907
+ state: Union[str, List[str]],
908
+ Trange: List[float],
909
+ Prange: List[float],
910
+ IS: float = 0,
911
+ xlim: Optional[List[float]] = None,
912
+ ylim: Optional[List[float]] = None,
913
+ line_type: str = "markers+lines",
914
+ tol: Optional[float] = None,
915
+ title: Optional[str] = None,
916
+ res: int = 10,
917
+ width: int = 500,
918
+ height: int = 400,
919
+ save_as: Optional[str] = None,
920
+ save_format: str = "png",
921
+ save_scale: float = 1,
922
+ show: bool = False,
923
+ messages: bool = False,
924
+ parallel: bool = True,
925
+ plot_it: bool = True) -> List[UnivariantResult]:
926
+ """
927
+ Solve for temperatures and pressures of equilibration for given logK value(s)
928
+ and produce an interactive T-P diagram.
929
+
930
+ This function calculates univariant curves in temperature-pressure (T-P) space
931
+ for one or more logK values. For each pressure in a range, it solves for the
932
+ temperature where the reaction achieves the target logK. The resulting curves
933
+ show phase boundaries or equilibrium conditions in T-P space.
934
+
935
+ Parameters
936
+ ----------
937
+ logK : float, int, or list
938
+ Logarithm (base 10) of equilibrium constant(s). Multiple values produce
939
+ multiple curves on the same plot.
940
+ species : str, int, or list of str or int
941
+ Name, formula, or database index of species involved in the reaction
942
+ coeff : int, float, or list
943
+ Reaction stoichiometric coefficients (negative for reactants, positive for products)
944
+ state : str or list of str
945
+ Physical state(s) of species: "aq", "cr", "gas", "liq"
946
+ Trange : list of two floats
947
+ [min, max] temperature range (°C) to search for solutions
948
+ Prange : list of two floats
949
+ [min, max] pressure range (bar) to calculate along
950
+ IS : float, default 0
951
+ Ionic strength for activity corrections (mol/kg)
952
+ xlim : list of two floats, optional
953
+ [min, max] range for x-axis (temperature) in plot
954
+ ylim : list of two floats, optional
955
+ [min, max] range for y-axis (pressure) in plot
956
+ line_type : str, default "markers+lines"
957
+ Plotly line type: "markers+lines", "markers", or "lines"
958
+ tol : float, optional
959
+ Convergence tolerance. Default: 1/(10^(n+2)) where n is decimal places in logK
960
+ title : str, optional
961
+ Plot title. Default: auto-generated from reaction
962
+ res : int, default 10
963
+ Number of pressure points to calculate along the curve
964
+ width : int, default 500
965
+ Plot width in pixels
966
+ height : int, default 400
967
+ Plot height in pixels
968
+ save_as : str, optional
969
+ Filename to save plot (without extension)
970
+ save_format : str, default "png"
971
+ Save format: "png", "jpg", "jpeg", "webp", "svg", "pdf", "html"
972
+ save_scale : float, default 1
973
+ Scale factor for saved plot
974
+ show : bool, default False
975
+ Display subcrt result tables
976
+ messages : bool, default False
977
+ Print informational messages
978
+ parallel : bool, default True
979
+ Use parallel processing across multiple logK values for faster computation.
980
+ Utilizes multiple CPU cores when processing multiple logK curves.
981
+ plot_it : bool, default True
982
+ Display the plot
983
+
984
+ Returns
985
+ -------
986
+ list of UnivariantResult
987
+ List of UnivariantResult objects, one for each logK value.
988
+ Each contains reaction information and T-P curve data.
989
+
990
+ Examples
991
+ --------
992
+ >>> from pychnosz import univariant_TP, reset
993
+ >>> reset()
994
+ >>>
995
+ >>> # Calcite-aragonite phase boundary
996
+ >>> result = univariant_TP(
997
+ ... logK=0,
998
+ ... species=["calcite", "aragonite"],
999
+ ... state=["cr", "cr"],
1000
+ ... coeff=[-1, 1],
1001
+ ... Trange=[0, 700],
1002
+ ... Prange=[2000, 16000]
1003
+ ... )
1004
+ >>>
1005
+ >>> # Multiple curves for K-feldspar stability
1006
+ >>> result = univariant_TP(
1007
+ ... logK=[-8, -6, -4, -2],
1008
+ ... species=["K-feldspar", "kaolinite", "H2O", "SiO2", "muscovite"],
1009
+ ... state=["cr", "cr", "liq", "aq", "cr"],
1010
+ ... coeff=[-1, -1, 1, 2, 1],
1011
+ ... Trange=[0, 350],
1012
+ ... Prange=[1, 5000],
1013
+ ... res=20
1014
+ ... )
1015
+
1016
+ Notes
1017
+ -----
1018
+ This function creates T-P diagrams by:
1019
+ 1. Generating a range of pressures from Prange[0] to Prange[1]
1020
+ 2. For each pressure, solving for T where logK matches the target
1021
+ 3. Plotting the resulting T-P points as a curve
1022
+
1023
+ For multiple logK values, each curve represents a different equilibrium
1024
+ condition. This is useful for:
1025
+ - Phase diagrams (e.g., mineral stability fields)
1026
+ - Isopleths (lines of constant logK)
1027
+ - Reaction boundaries
1028
+
1029
+ Requires plotly for interactive plotting. If plotly is not installed,
1030
+ set plot_it=False to just return the data without plotting.
1031
+
1032
+ References
1033
+ ----------
1034
+ Based on univariant_TP from pyCHNOSZ by Grayson Boyer
1035
+ """
1036
+
1037
+ # Check if plotly is available
1038
+ if plot_it and not PLOTLY_AVAILABLE:
1039
+ warnings.warn("plotly is not installed. Set plot_it=False to suppress this warning, "
1040
+ "or install plotly with: pip install plotly")
1041
+ plot_it = False
1042
+
1043
+ # Ensure logK is a list
1044
+ if not isinstance(logK, list):
1045
+ logK = [logK]
1046
+
1047
+ # Create plotly figure
1048
+ if plot_it:
1049
+ fig = go.Figure()
1050
+
1051
+ output = []
1052
+
1053
+ # Generate pressure array
1054
+ pressures = np.linspace(Prange[0], Prange[1], res)
1055
+
1056
+ # Process each logK value (in parallel if enabled)
1057
+ if parallel and len(logK) > 1:
1058
+ # Parallel processing
1059
+ max_workers = min(len(logK), multiprocessing.cpu_count())
1060
+
1061
+ # Prepare arguments for each logK value
1062
+ args_list = [
1063
+ (this_logK, species, state, coeff, pressures, Trange, IS, tol, show, messages)
1064
+ for this_logK in logK
1065
+ ]
1066
+
1067
+ # Process in parallel
1068
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
1069
+ # Submit all tasks
1070
+ future_to_logK = {
1071
+ executor.submit(_process_single_logK, args): args[0]
1072
+ for args in args_list
1073
+ }
1074
+
1075
+ # Collect results as they complete (maintains order via logK list)
1076
+ results_dict = {}
1077
+ for future in as_completed(future_to_logK):
1078
+ this_logK = future_to_logK[future]
1079
+ try:
1080
+ out = future.result()
1081
+ results_dict[this_logK] = out
1082
+ except Exception as e:
1083
+ if messages:
1084
+ print(f"Error processing logK={this_logK}: {str(e)}")
1085
+ # Create empty result
1086
+ results_dict[this_logK] = None
1087
+
1088
+ # Reorder results to match input logK order
1089
+ for this_logK in logK:
1090
+ out = results_dict.get(this_logK)
1091
+ if out is not None:
1092
+ output.append(out)
1093
+
1094
+ # Add to plot if we have valid data
1095
+ if plot_it and not out.out['T'].isnull().all():
1096
+ fig.add_trace(go.Scatter(
1097
+ x=out.out['T'],
1098
+ y=out.out['P'],
1099
+ mode=line_type,
1100
+ name=f"logK={this_logK}",
1101
+ text=[f"logK={this_logK}" for _ in range(len(out.out['T']))],
1102
+ hovertemplate='%{text}<br>T, °C=%{x:.2f}<br>P, bar=%{y:.2f}<extra></extra>',
1103
+ ))
1104
+ elif out.out['T'].isnull().all():
1105
+ if messages:
1106
+ print(f"Could not find any T or P values in this range that correspond to a logK value of {this_logK}")
1107
+
1108
+ else:
1109
+ # Sequential processing (original code)
1110
+ for this_logK in logK:
1111
+ # Set tolerance if not specified
1112
+ if tol is None:
1113
+ logK_str = str(float(this_logK))
1114
+ if '.' in logK_str:
1115
+ n_decimals = len(logK_str.split('.')[1].rstrip('0'))
1116
+ else:
1117
+ n_decimals = 0
1118
+ this_tol = 10 ** (-(n_decimals + 2))
1119
+ if this_tol > 1e-5:
1120
+ this_tol = 1e-5
1121
+ else:
1122
+ this_tol = tol
1123
+
1124
+ # Solve for T at each pressure
1125
+ out = unicurve(
1126
+ solve="T",
1127
+ logK=this_logK,
1128
+ species=species,
1129
+ state=state,
1130
+ coeff=coeff,
1131
+ pressures=list(pressures),
1132
+ minT=Trange[0],
1133
+ maxT=Trange[1],
1134
+ IS=IS,
1135
+ tol=this_tol,
1136
+ show=show,
1137
+ messages=messages,
1138
+ plot_it=False # Don't plot individual curves - univariant_TP makes its own plot
1139
+ )
1140
+
1141
+ # Add to plot if we have valid data
1142
+ if plot_it and not out.out['T'].isnull().all():
1143
+ fig.add_trace(go.Scatter(
1144
+ x=out.out['T'],
1145
+ y=out.out['P'],
1146
+ mode=line_type,
1147
+ name=f"logK={this_logK}",
1148
+ text=[f"logK={this_logK}" for _ in range(len(out.out['T']))],
1149
+ hovertemplate='%{text}<br>T, °C=%{x:.2f}<br>P, bar=%{y:.2f}<extra></extra>',
1150
+ ))
1151
+ elif out.out['T'].isnull().all():
1152
+ if messages:
1153
+ print(f"Could not find any T or P values in this range that correspond to a logK value of {this_logK}")
1154
+
1155
+ output.append(out)
1156
+
1157
+ # Generate plot title if not specified
1158
+ if plot_it:
1159
+ if title is None and len(output) > 0 and output[0].reaction is not None:
1160
+ react_grid = output[0].reaction
1161
+
1162
+ # Build reaction string
1163
+ reactants = []
1164
+ products = []
1165
+ for i, row in react_grid.iterrows():
1166
+ coeff_val = row['coeff']
1167
+ name = row['name'] if row['name'] != 'water' else 'H2O'
1168
+
1169
+ if coeff_val < 0:
1170
+ coeff_str = str(int(-coeff_val)) if -coeff_val != 1 else ""
1171
+ reactants.append(f"{coeff_str} {name}".strip())
1172
+ elif coeff_val > 0:
1173
+ coeff_str = str(int(coeff_val)) if coeff_val != 1 else ""
1174
+ products.append(f"{coeff_str} {name}".strip())
1175
+
1176
+ title = " + ".join(reactants) + " = " + " + ".join(products)
1177
+
1178
+ # Update layout
1179
+ fig.update_layout(
1180
+ template="simple_white",
1181
+ title=str(title) if title else "",
1182
+ xaxis_title="T, °C",
1183
+ yaxis_title="P, bar",
1184
+ width=width,
1185
+ height=height,
1186
+ hoverlabel=dict(bgcolor="white"),
1187
+ )
1188
+
1189
+ # Set axis limits if specified
1190
+ if xlim is not None:
1191
+ fig.update_xaxes(range=xlim)
1192
+ if ylim is not None:
1193
+ fig.update_yaxes(range=ylim)
1194
+
1195
+ # Configure plot controls
1196
+ config = {
1197
+ 'displaylogo': False,
1198
+ 'modeBarButtonsToRemove': ['resetScale2d', 'toggleSpikelines'],
1199
+ 'toImageButtonOptions': {
1200
+ 'format': save_format,
1201
+ 'filename': save_as if save_as else 'univariant_TP',
1202
+ 'height': height,
1203
+ 'width': width,
1204
+ 'scale': save_scale,
1205
+ },
1206
+ }
1207
+
1208
+ # Save plot if requested
1209
+ if save_as is not None:
1210
+ full_filename = f"{save_as}.{save_format}"
1211
+ if save_format == 'html':
1212
+ fig.write_html(full_filename)
1213
+ else:
1214
+ fig.write_image(full_filename, format=save_format,
1215
+ width=width, height=height, scale=save_scale)
1216
+ if messages:
1217
+ print(f"Plot saved to {full_filename}")
1218
+
1219
+ # Display plot
1220
+ fig.show(config=config)
1221
+
1222
+ # Store figure in all result objects
1223
+ for out in output:
1224
+ out.fig = fig
1225
+
1226
+ return output