PetThermoTools 0.2.41__tar.gz → 0.2.42__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 (25) hide show
  1. {PetThermoTools-0.2.41/src/PetThermoTools.egg-info → PetThermoTools-0.2.42}/PKG-INFO +1 -1
  2. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Barom.py +12 -0
  3. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/GenFuncs.py +63 -24
  4. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Liq.py +46 -4
  5. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/MELTS.py +3 -1
  6. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Melting.py +94 -52
  7. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Path.py +2 -0
  8. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/PhaseDiagrams.py +11 -0
  9. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Plotting.py +395 -164
  10. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Saturation.py +5 -0
  11. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/_version.py +1 -1
  12. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42/src/PetThermoTools.egg-info}/PKG-INFO +1 -1
  13. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/LICENSE.txt +0 -0
  14. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/README.md +0 -0
  15. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/setup.cfg +0 -0
  16. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/setup.py +0 -0
  17. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Compositions.py +0 -0
  18. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Holland.py +0 -0
  19. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Installation.py +0 -0
  20. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/Path_wrappers.py +0 -0
  21. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools/__init__.py +0 -0
  22. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools.egg-info/SOURCES.txt +0 -0
  23. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools.egg-info/dependency_links.txt +0 -0
  24. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools.egg-info/requires.txt +0 -0
  25. {PetThermoTools-0.2.41 → PetThermoTools-0.2.42}/src/PetThermoTools.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PetThermoTools
3
- Version: 0.2.41
3
+ Version: 0.2.42
4
4
  Summary: PetThermoTools
5
5
  Home-page: https://github.com/gleesonm1/PetThermoTools
6
6
  Author: Matthew Gleeson
@@ -199,6 +199,18 @@ def mineral_cosaturation(Model="MELTSv1.0.2", cores=int(np.floor(multiprocessing
199
199
  fO2_offset = to_float(fO2_offset)
200
200
 
201
201
  comp = bulk.copy()
202
+
203
+ if fO2_buffer is not None:
204
+ if fO2_buffer != "NNO":
205
+ if fO2_buffer != "FMQ":
206
+ raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
207
+
208
+ if "MELTS" not in Model:
209
+ if fO2_buffer == "FMQ":
210
+ fO2_buffer = "qfm"
211
+ if fO2_buffer == "NNO":
212
+ fO2_buffer = "nno"
213
+
202
214
  if H2O_Sat:
203
215
  comp['H2O_Liq'] = 20
204
216
 
@@ -4,52 +4,53 @@ import pandas as pd
4
4
  # from PetThermoTools.Liq import *
5
5
  # from PetThermoTools.Crystallise import *
6
6
  from PetThermoTools.MELTS import *
7
+ from PetThermoTools.Compositions import *
7
8
  # try:
8
9
  # from PetThermoTools.Holland import *
9
10
  # except:
10
11
  # pass
11
12
 
12
13
  Names = {'liquid1': '_Liq',
13
- 'liquid2': '_Liq2',
14
- 'liquid3': '_Liq3',
15
- 'liquid4': '_Liq4',
16
14
  'olivine1': '_Ol',
17
- 'olivine2': '_Ol2',
15
+ 'orthopyroxene1': '_Opx',
18
16
  'clinopyroxene1': '_Cpx',
19
- 'clinopyroxene2': '_Cpx2',
17
+ 'garnet1': '_Grt',
18
+ 'spinel1': '_Sp',
19
+ 'k-feldspar1': '_Kspar',
20
+ 'quartz1': '_Qtz',
21
+ 'rhm-oxide1': '_Rhm',
22
+ 'apatite1': '_Apa',
23
+ 'olivine2': '_Ol2',
20
24
  'plagioclase1': '_Plag',
25
+ 'clinopyroxene2': '_Cpx2',
21
26
  'plagioclase2': '_Plag2',
22
- 'spinel1': '_Sp',
23
27
  'spinel2': '_Sp2',
24
- 'k-feldspar1': '_Kspar',
25
28
  'k-feldspar2': '_Kspar2',
26
- 'garnet1': '_Grt',
27
29
  'garnet2': '_Grt2',
28
- 'rhm-oxide1': '_Rhm',
29
30
  'rhm-oxide2': '_Rhm2',
30
- 'quartz1': '_Qtz',
31
31
  'quartz2': '_Qtz2',
32
- 'orthopyroxene1': '_Opx',
33
32
  'orthopyroxene2': '_Opx2',
34
- 'apatite1': '_Apa',
35
- 'apatite2': '_Apa2'}
33
+ 'apatite2': '_Apa2',
34
+ 'liquid2': '_Liq2',
35
+ 'liquid3': '_Liq3',
36
+ 'liquid4': '_Liq4'}
36
37
 
37
38
  Names_MM = {'liq1': '_Liq',
38
- 'liq2': '_Liq2',
39
- 'liq3': '_Liq3',
40
- 'liq4': '_Liq4',
41
39
  'ol1': '_Ol',
42
- 'ol2': '_Ol2',
40
+ 'opx1': '_Opx',
43
41
  'cpx1': '_Cpx',
42
+ 'g1': '_Grt',
43
+ 'spl1': '_Sp',
44
+ 'fsp1': '_Plag',
45
+ 'ol2': '_Ol2',
44
46
  'cpx2': '_Cpx2',
45
- 'opx1': '_Opx',
46
47
  'opx2': '_Opx2',
47
- 'g1': '_Grt',
48
48
  'g2': '_Grt2',
49
- 'fsp1': '_Plag',
50
49
  'fsp2': '_Plag2',
51
- 'spl1': '_Sp',
52
- 'spl2': '_Sp2'}
50
+ 'spl2': '_Sp2',
51
+ 'liq2': '_Liq2',
52
+ 'liq3': '_Liq3',
53
+ 'liq4': '_Liq4'}
53
54
 
54
55
  def to_float(x):
55
56
  if x is None:
@@ -104,6 +105,36 @@ def supCalc(Model = "MELTSv1.0.2", bulk = None, phase = None, T_C = None, P_bar
104
105
 
105
106
  return Results
106
107
 
108
+ def comp_check(comp_lith, Model, MELTS_filter, Fe3Fet):
109
+ if type(comp_lith) == str:
110
+ if Model != "pyMelt":
111
+ comp = Compositions[comp_lith]
112
+ else:
113
+ comp = comp_lith
114
+ else:
115
+ comp = comp_lith.copy()
116
+
117
+ # if comp is entered as a pandas series, it must first be converted to a dict
118
+ if Model != "pyMelt":
119
+ if type(comp) == pd.core.series.Series:
120
+ comp = comp.to_dict()
121
+
122
+ comp = comp_fix(Model = Model, comp = comp, Fe3Fet_Liq = Fe3Fet)
123
+
124
+ if "MELTS" in Model and MELTS_filter == True:
125
+ if type(comp) == pd.core.frame.DataFrame:
126
+ comp['K2O_Liq'] = np.zeros(len(comp['SiO2_Liq']))
127
+ comp['P2O5_Liq'] = np.zeros(len(comp['SiO2_Liq']))
128
+ comp['H2O_Liq'] = np.zeros(len(comp['SiO2_Liq']))
129
+ comp['CO2_Liq'] = np.zeros(len(comp['SiO2_Liq']))
130
+ else:
131
+ comp['K2O_Liq'] = 0
132
+ comp['P2O5_Liq'] = 0
133
+ comp['H2O_Liq'] = 0
134
+ comp['CO2_Liq'] = 0
135
+
136
+ return comp
137
+
107
138
 
108
139
  def comp_fix(Model = None, comp = None, Fe3Fet_Liq = None, H2O_Liq = None, CO2_Liq = None):
109
140
  '''
@@ -356,12 +387,20 @@ def stich_work(Results = None, Order = None, Model = "MELTS", Frac_fluid = None,
356
387
  Results_Mass['fluid1_cumsum'] = Results_Mass['fluid1'].cumsum()
357
388
  elif Frac_fluid is None:
358
389
  for n in SN:
359
- if n != 'liquid1' and n!= 'fluid1':
390
+ if n != 'liquid1' and n!= 'fluid1' and n != 'liq1' and n != 'fl1':
360
391
  Results_Mass[n + '_cumsum'] = Results_Mass[n].cumsum()
392
+ if 'liq1' in SN:
393
+ Results_Mass[n + '_cumsum'] = Results_Mass.loc[0, 'liq1'] - Results_Mass.loc[:,Results_Mass.columns.str.contains('_cumsum')].sum(axis = 1)
394
+ elif 'liquid1' in SN:
395
+ Results_Mass[n + '_cumsum'] = Results_Mass.loc[0, 'liquid1'] - Results_Mass.loc[:,Results_Mass.columns.str.contains('_cumsum')].sum(axis = 1)
361
396
  else:
362
397
  for n in SN:
363
- if n != 'liquid1':
398
+ if n != 'liquid1' and n != 'liq1':
364
399
  Results_Mass[n + '_cumsum'] = Results_Mass[n].cumsum()
400
+ if 'liq1' in SN:
401
+ Results_Mass[n + '_cumsum'] = Results_Mass.loc[0, 'liq1'] - Results_Mass.loc[:,Results_Mass.columns.str.contains('_cumsum')].sum(axis = 1)
402
+ elif 'liquid1' in SN:
403
+ Results_Mass[n + '_cumsum'] = Results_Mass.loc[0, 'liquid1'] - Results_Mass.loc[:,Results_Mass.columns.str.contains('_cumsum')].sum(axis = 1)
365
404
 
366
405
  Results_All = Results['Conditions'].copy()
367
406
  for R in Results:
@@ -16,6 +16,26 @@ def equilibrate_multi(cores = None, Model = None, bulk = None, T_C = None, P_bar
16
16
  Fe3Fet_Liq = None, H2O_Liq = None, CO2_Liq = None, fO2_buffer = None, fO2_offset = None,
17
17
  timeout = None, copy_columns = None, Suppress = None):
18
18
 
19
+ T_C = to_float(T_C)
20
+
21
+ P_bar = to_float(P_bar)
22
+
23
+ H2O_Liq = to_float(H2O_Liq)
24
+ CO2_Liq = to_float(CO2_Liq)
25
+ Fe3Fet_Liq = to_float(Fe3Fet_Liq)
26
+ fO2_offset = to_float(fO2_offset)
27
+
28
+ if fO2_buffer is not None:
29
+ if fO2_buffer != "NNO":
30
+ if fO2_buffer != "FMQ":
31
+ raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
32
+
33
+ if "MELTS" not in Model:
34
+ if fO2_buffer == "FMQ":
35
+ fO2_buffer = "qfm"
36
+ if fO2_buffer == "NNO":
37
+ fO2_buffer = "nno"
38
+
19
39
  if "MELTS" in Model:
20
40
  try:
21
41
  from meltsdynamic import MELTSdynamic
@@ -410,6 +430,17 @@ def findCO2_multi(cores = None, Model = None, bulk = None, T_initial_C = None, P
410
430
  except:
411
431
  Warning('alphaMELTS for Python files are not on the python path. \n Please add these files to the path running \n import sys \n sys.path.append(r"insert_your_path_to_melts_here") \n You are looking for the location of the meltsdynamic.py file')
412
432
 
433
+ if fO2_buffer is not None:
434
+ if fO2_buffer != "NNO":
435
+ if fO2_buffer != "FMQ":
436
+ raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
437
+
438
+ if "MELTS" not in Model:
439
+ if fO2_buffer == "FMQ":
440
+ fO2_buffer = "qfm"
441
+ if fO2_buffer == "NNO":
442
+ fO2_buffer = "nno"
443
+
413
444
  comp = bulk.copy()
414
445
 
415
446
  if Model is None:
@@ -607,11 +638,22 @@ def findLiq_multi(cores = None, Model = None, bulk = None, T_initial_C = None, P
607
638
 
608
639
  comp = bulk.copy()
609
640
 
641
+ if fO2_buffer is not None:
642
+ if fO2_buffer != "NNO":
643
+ if fO2_buffer != "FMQ":
644
+ raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
645
+
646
+ if "MELTS" not in Model:
647
+ if fO2_buffer == "FMQ":
648
+ fO2_buffer = "qfm"
649
+ if fO2_buffer == "NNO":
650
+ fO2_buffer = "nno"
651
+
610
652
  if Model is None:
611
653
  Model = "MELTSv1.0.2"
612
654
 
613
- if Model == "Holland":
614
- import pyMAGEMINcalc as MM
655
+ # if Model == "Holland":
656
+ # import pyMAGEMINcalc as MM
615
657
 
616
658
  # if comp is entered as a pandas series, it must first be converted to a dict
617
659
  if type(comp) == pd.core.series.Series:
@@ -792,8 +834,8 @@ def findLiq_multi(cores = None, Model = None, bulk = None, T_initial_C = None, P
792
834
  else:
793
835
  return Res
794
836
  else:
795
- T_Liq = MM.findLiq_multi(P_bar = P_bar, T_initial_C = T_initial_C, comp = comp)
796
- return T_Liq
837
+ # T_Liq = MM.findLiq_multi(P_bar = P_bar, T_initial_C = T_initial_C, comp = comp)
838
+ return "find liquidus calculations are currently not available through the MAGEMin models. This is an issue I'm working to fix as soon as possible."
797
839
 
798
840
  def findCO2(q, index, *, Model = None, P_bar = None, T_initial_C = None, comp = None, fO2_buffer = None, fO2_offset = None):
799
841
  T_Liq = 0
@@ -1572,7 +1572,9 @@ def findSatPressure_MELTS(Model = None, T_C_init = None, T_fixed_C = None, P_bar
1572
1572
  else:
1573
1573
  return out
1574
1574
 
1575
- def AdiabaticDecompressionMelting_MELTS(Model = None, comp = None, Tp_C = None, P_path_bar = None, P_start_bar = None, P_end_bar = None, dp_bar = None, Frac = False, fO2_buffer = None, fO2_offset = None):
1575
+ def AdiabaticDecompressionMelting_MELTS(Model = None, comp = None, Tp_C = None, Tp_Method = None,
1576
+ P_path_bar = None, P_start_bar = None, P_end_bar = None, dp_bar = None,
1577
+ Frac = False, fO2_buffer = None, fO2_offset = None):
1576
1578
  try:
1577
1579
  import pyMelt as m
1578
1580
  Lithologies = {'KLB-1': m.lithologies.matthews.klb1(),
@@ -10,37 +10,6 @@ from multiprocessing import Process
10
10
  import time
11
11
  import sys
12
12
  from tqdm.notebook import tqdm, trange
13
- # import pyMelt as m
14
-
15
- def comp_check(comp_lith, Model, MELTS_filter, Fe3Fet):
16
- if type(comp_lith) == str:
17
- if Model != "pyMelt":
18
- comp = Compositions[comp_lith]
19
- else:
20
- comp = comp_lith
21
- else:
22
- comp = comp_lith.copy()
23
-
24
- # if comp is entered as a pandas series, it must first be converted to a dict
25
- if Model != "pyMelt":
26
- if type(comp) == pd.core.series.Series:
27
- comp = comp.to_dict()
28
-
29
- comp = comp_fix(Model = Model, comp = comp, Fe3Fet_Liq = Fe3Fet)
30
-
31
- if "MELTS" in Model and MELTS_filter == True:
32
- if type(comp) == pd.core.frame.DataFrame:
33
- comp['K2O_Liq'] = np.zeros(len(comp['SiO2_Liq']))
34
- comp['P2O5_Liq'] = np.zeros(len(comp['SiO2_Liq']))
35
- comp['H2O_Liq'] = np.zeros(len(comp['SiO2_Liq']))
36
- comp['CO2_Liq'] = np.zeros(len(comp['SiO2_Liq']))
37
- else:
38
- comp['K2O_Liq'] = 0
39
- comp['P2O5_Liq'] = 0
40
- comp['H2O_Liq'] = 0
41
- comp['CO2_Liq'] = 0
42
-
43
- return comp
44
13
 
45
14
  def AdiabaticDecompressionMelting(cores = multiprocessing.cpu_count(),
46
15
  Model = "pMELTS", bulk = "KLB-1", comp_lith_1 = None,
@@ -48,6 +17,67 @@ def AdiabaticDecompressionMelting(cores = multiprocessing.cpu_count(),
48
17
  P_start_bar = 30000, P_end_bar = 2000, dp_bar = 200,
49
18
  P_path_bar = None, Frac = False, prop = None,
50
19
  fO2_buffer = None, fO2_offset = None, Fe3Fet = None, MELTS_filter = True):
20
+ """
21
+ Perform adiabatic decompression melting calculations using MELTS, MAGEMin, or pyMelt.
22
+
23
+ Simulates mantle melting along an adiabatic upwelling path (e.g., ridge or plume) with user-defined
24
+ starting potential temperature, pressure range, and step size. Supports single-lithology mantle sources
25
+ (e.g., KLB-1) at present, with expansion to multi-lithology systems in development.
26
+
27
+ Parameters
28
+ ----------
29
+ cores : int, optional
30
+ Number of CPU cores to use for multiprocessing. Defaults to total available.
31
+ Model : str, optional
32
+ Thermodynamic model. MELTS variants: "MELTSv1.0.2", "MELTSv1.1.0", "MELTSv1.2.0", "pMELTS";
33
+ or MAGEMin: "Green2025", "Weller2024". Alternatively calculations can be performed using
34
+ pyMelt (Matthews et al. 2020): "pyMelt"
35
+ bulk : dict ot str, optional
36
+ Bulk composition name or composition dictionary.
37
+ Default is "KLB-1".
38
+ Tp_C : float or np.ndarray, optional
39
+ Mantle potential temperature(s) in °C. Default is 1350.
40
+ Tp_Method : str, optional
41
+ Method to calculate the starting pressure for adiabatic melting. Default is "pyMelt".
42
+ P_start_bar, P_end_bar, dp_bar : float or array, optional
43
+ Starting, ending, and step size pressures (in bar) for adiabatic decompression.
44
+ Defaults: 30000, 2000, and 200, respectively.
45
+ P_path_bar : np.ndarray, optional
46
+ User-specified pressure path (in bar). If given, overrides `P_start_bar`, `P_end_bar`, and `dp_bar`.
47
+ fO2_buffer : {"FMQ", "NNO"}, optional
48
+ Redox buffer for constraining oxygen fugacity.
49
+ fO2_offset : float, optional
50
+ Offset (log units) from the chosen fO2 buffer.
51
+ Fe3Fet : float, optional
52
+ Initial Fe³⁺/ΣFe ratio for the bulk composition. If None, values is taken from the "bulk" variable or set according to fO2 buffer positions.
53
+ MELTS_filter : bool, default=True
54
+ If True, filters oxide components to avoid issues in MELTS calculations (e.g., K2O content set to 0.0).
55
+
56
+ Returns
57
+ -------
58
+ Results : dict
59
+ Dictionary containing DataFrames for the system and phase compositions and properties.
60
+
61
+ Notes
62
+ -----
63
+ - Currently limited to single-lithology melting.
64
+ - Normalizes output mass so that total initial mass = 1.
65
+
66
+ Examples
67
+ --------
68
+ Run a single adiabatic decompression path from 3.0 GPa to 0.2 GPa:
69
+
70
+ >>> results = AdiabaticDecompressionMelting(Model="pMELTS", bulk="KLB-1",
71
+ ... Tp_C=1350, P_start_bar=30000,
72
+ ... P_end_bar=2000, dp_bar=200)
73
+
74
+ Run with an explicit pressure path:
75
+
76
+ >>> import numpy as np
77
+ >>> P_path = np.linspace(30000, 2000, 20)
78
+ >>> results = AdiabaticDecompressionMelting(Model="pMELTS", comp_lith_1="KLB-1",
79
+ ... P_path_bar=P_path, Tp_C=1400)
80
+ """
51
81
 
52
82
  Tp_C = to_float(Tp_C)
53
83
 
@@ -59,22 +89,33 @@ def AdiabaticDecompressionMelting(cores = multiprocessing.cpu_count(),
59
89
  Fe3Fet = to_float(Fe3Fet)
60
90
  fO2_offset = to_float(fO2_offset)
61
91
 
62
-
63
- if Tp_Method == "pyMelt":
64
- try:
65
- import pyMelt as m
66
- Lithologies = {'KLB-1': m.lithologies.matthews.klb1(),
67
- 'KG1': m.lithologies.matthews.kg1(),
68
- 'G2': m.lithologies.matthews.eclogite(),
69
- 'hz': m.lithologies.shorttle.harzburgite()}
70
- except ImportError:
71
- raise RuntimeError('You havent installed pyMelt or there is an error when importing pyMelt. pyMelt is currently required to estimate the starting point for the melting calculations.')
92
+ if fO2_buffer is not None:
93
+ if fO2_buffer != "NNO":
94
+ if fO2_buffer != "FMQ":
95
+ raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
96
+
97
+ if "MELTS" not in Model:
98
+ if fO2_buffer == "FMQ":
99
+ fO2_buffer = "qfm"
100
+ if fO2_buffer == "NNO":
101
+ fO2_buffer = "nno"
102
+
103
+ # if Tp_Method == "pyMelt":
104
+ # try:
105
+ # import pyMelt as m
106
+ # Lithologies = {'KLB-1': m.lithologies.matthews.klb1(),
107
+ # 'KG1': m.lithologies.matthews.kg1(),
108
+ # 'G2': m.lithologies.matthews.eclogite(),
109
+ # 'hz': m.lithologies.shorttle.harzburgite()}
110
+ # except ImportError:
111
+ # raise RuntimeError('You havent installed pyMelt or there is an error when importing pyMelt. pyMelt is currently required to estimate the starting point for the melting calculations.')
72
112
 
73
113
  if bulk is not None and comp_lith_1 is None:
74
114
  comp_lith_1 = bulk
75
115
 
76
116
  comp_1 = comp_check(comp_lith_1, Model, MELTS_filter, Fe3Fet)
77
117
 
118
+ # place holders for when code is expanded to account for multi-lithology mantle
78
119
  if comp_lith_2 is not None:
79
120
  comp_2 = comp_check(comp_lith_2, Model, MELTS_filter, Fe3Fet)
80
121
  else:
@@ -85,6 +126,8 @@ def AdiabaticDecompressionMelting(cores = multiprocessing.cpu_count(),
85
126
  else:
86
127
  comp_3 = None
87
128
 
129
+
130
+ # At present calculations only work for a single simulation - this represents a placeholder for when the code is expanded to account for multiple simulations
88
131
  One = 0
89
132
  if Model != "pyMelt":
90
133
  if type(comp_1) == pd.core.frame.DataFrame: # simplest scenario - one calculation per bulk composition imported
@@ -125,7 +168,7 @@ def AdiabaticDecompressionMelting(cores = multiprocessing.cpu_count(),
125
168
  if One == 1:
126
169
  p = Process(target = AdiabaticMelt, args = (q, 1),
127
170
  kwargs = {'Model': Model, 'comp_1': comp_1, 'comp_2': comp_2, 'comp_3': comp_3,
128
- 'Tp_C': Tp_C, 'P_path_bar': P_path_bar,
171
+ 'Tp_C': Tp_C, 'Tp_Method': Tp_Method, 'P_path_bar': P_path_bar,
129
172
  'P_start_bar': P_start_bar, 'P_end_bar': P_end_bar, 'dp_bar': dp_bar,
130
173
  'fO2_buffer': fO2_buffer, 'fO2_offset': fO2_offset, 'Frac': Frac, 'prop': prop})
131
174
 
@@ -167,7 +210,7 @@ def AdiabaticDecompressionMelting(cores = multiprocessing.cpu_count(),
167
210
 
168
211
  return Results
169
212
 
170
- def AdiabaticMelt(q, index, *, Model = None, comp_1 = None, comp_2 = None, comp_3 = None,
213
+ def AdiabaticMelt(q, index, *, Model = None, comp_1 = None, comp_2 = None, comp_3 = None, Tp_Method = "pyMelt",
171
214
  Tp_C = None, P_start_bar = None, P_end_bar = None, dp_bar = None, P_path_bar = None,
172
215
  Frac = None, fO2_buffer = None, fO2_offset = None, prop = None):
173
216
  '''
@@ -177,7 +220,7 @@ def AdiabaticMelt(q, index, *, Model = None, comp_1 = None, comp_2 = None, comp_
177
220
  Results = {}
178
221
  if "MELTS" in Model:
179
222
  try:
180
- Results = AdiabaticDecompressionMelting_MELTS(Model = Model, comp = comp_1, Tp_C = Tp_C,
223
+ Results = AdiabaticDecompressionMelting_MELTS(Model = Model, comp = comp_1, Tp_C = Tp_C, Tp_Method = "pyMelt",
181
224
  P_path_bar = P_path_bar, P_start_bar = P_start_bar, P_end_bar = P_end_bar, dp_bar = dp_bar,
182
225
  fO2_buffer = fO2_buffer, fO2_offset = fO2_offset)
183
226
  q.put([Results, index])
@@ -250,10 +293,6 @@ def AdiabaticMelt(q, index, *, Model = None, comp_1 = None, comp_2 = None, comp_
250
293
  return
251
294
 
252
295
  else:
253
- # import pyMAGEMINcalc as MM
254
- # Results = MM.AdiabaticDecompressionMelting(comp = comp_1, T_p_C = Tp_C, P_start_kbar = P_start_bar/1000, P_end_kbar = P_end_bar/1000, dp_kbar = dp_bar/1000, Frac = 0)
255
- # print('Note that the ability to use MAGEMin to performed adiabatic decompression melting in PetThermoTools has been temporarily disabled. The underlying issue will be fixed soon and this funciton will once again become available.')
256
-
257
296
  try:
258
297
  import pyMelt as m
259
298
  Lithologies = {'KLB-1': m.lithologies.matthews.klb1(),
@@ -263,10 +302,13 @@ def AdiabaticMelt(q, index, *, Model = None, comp_1 = None, comp_2 = None, comp_
263
302
  except ImportError:
264
303
  raise RuntimeError('You havent installed pyMelt or there is an error when importing pyMelt. pyMelt is currently required to estimate the starting point for the melting calculations.')
265
304
 
266
- lz = m.lithologies.matthews.klb1()
267
- mantle = m.mantle([lz], [1], ['Lz'])
268
- T_start_C = mantle.adiabat(P_start_bar/10000.0, Tp_C)
269
-
305
+ if Tp_Method == "pyMelt":
306
+ lz = m.lithologies.matthews.klb1()
307
+ mantle = m.mantle([lz], [1], ['Lz'])
308
+ T_start_C = mantle.adiabat(P_start_bar/10000.0, Tp_C)
309
+ else:
310
+ T_start_C = None
311
+
270
312
  from juliacall import Main as jl, convert as jlconvert
271
313
 
272
314
  jl.seval("using MAGEMinCalc")
@@ -170,6 +170,8 @@ def multi_path(cores = None, Model = None, bulk = None, comp = None, Frac_solid
170
170
  if "MELTS" not in Model:
171
171
  if fO2_buffer == "FMQ":
172
172
  fO2_buffer = "qfm"
173
+ if fO2_buffer == "NNO":
174
+ fO2_buffer = "nno"
173
175
 
174
176
  # ensure the bulk composition has the correct headers etc.
175
177
  comp = comp_fix(Model = Model, comp = comp, Fe3Fet_Liq = Fe3Fet_init, H2O_Liq = H2O_init, CO2_Liq = CO2_init)
@@ -103,6 +103,17 @@ def phaseDiagram_calc(cores = None, Model = None, bulk = None, T_C = None, P_bar
103
103
 
104
104
  comp = bulk.copy()
105
105
 
106
+ if fO2_buffer is not None:
107
+ if fO2_buffer != "NNO":
108
+ if fO2_buffer != "FMQ":
109
+ raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
110
+
111
+ if "MELTS" not in Model:
112
+ if fO2_buffer == "FMQ":
113
+ fO2_buffer = "qfm"
114
+ if fO2_buffer == "NNO":
115
+ fO2_buffer = "nno"
116
+
106
117
  if cores is None:
107
118
  cores = multiprocessing.cpu_count()
108
119
 
@@ -3,128 +3,237 @@ import pandas as pd
3
3
  from shapely.geometry import MultiPoint, Point, Polygon
4
4
  import matplotlib.pyplot as plt
5
5
  from matplotlib import cm
6
+ import matplotlib.colors as mc
6
7
  from PetThermoTools.GenFuncs import *
7
-
8
- def harker(Results = None, x_axis = None, y_axis = None, phase = None, line_style = None, line_color = None, data = None, d_color = None, d_marker = None, label = None):
9
- '''
10
- Construct harker plots.
11
-
12
- Parameters:
13
- -----------
14
- Results: dict
15
- Contains DataFrames with the results of the MELTS or MAGEMin calculations.
16
-
17
- x_axis: str
18
- Oxide to be placed on the x-axis, Default = "MgO".
19
-
20
- y_axis: list
21
- Oxides to be displayed on the y-axes.
22
-
23
- phase: str
24
- Phase compositions to be plotted. Plots the liquid component by default.
25
-
26
- line_style: str
27
- Line style to use for the MELTS/MAGEMin results. Default = '-'.
28
-
29
- line_color: str or tuple
30
- Color of the line used to display the MELTS/MAGEMin results. Black as default.
31
-
32
- data: DataFrame
33
- Optional. Include natural or experimental data to plot against the calculation results.
34
-
35
- d_color: str or tuple
36
- Color of the symbols used to display the natural/experimental data.
37
-
38
- d_marker: str
39
- Marker style for the natural/experimental data.
40
- '''
8
+ from itertools import zip_longest
9
+
10
+ def harker(Results=None, x_axis="MgO", y_axis=("SiO2", "TiO2", "Al2O3", "Cr2O3", "FeOt", "CaO", "Na2O", "K2O"),
11
+ phase="liquid1", line_color=None, data=None, d_color=None, d_marker=None,
12
+ legend=True, legend_loc=None,
13
+ xlim=None, ylim=None):
14
+ """
15
+ Generates a Harker diagram (oxide vs. MgO or another oxide) from model results.
16
+
17
+ Parameters
18
+ ----------
19
+ Results : dict or None, optional
20
+ MELTS or MAGEMin results dictionary. If multiple results, they are overlaid.
21
+ x_axis : str, default="MgO"
22
+ Oxide to use for the x-axis.
23
+ y_axis : tuple of str, default=("SiO2","TiO2","Al2O3","Cr2O3","FeOt","CaO","Na2O","K2O")
24
+ Oxides to plot on y-axes. Plotted in groups of three per row.
25
+ phase : str, default="liquid1"
26
+ Phase to plot (must be in PetThermoTools.GenFuncs.Names).
27
+ line_color : str or None, optional
28
+ Line color. If None, uses matplotlib's default cycle.
29
+ data : pd.DataFrame, dict of DataFrames, or str, optional
30
+ External data to plot for comparison. If str, treated as CSV path.
31
+ If dict, plots each DataFrame with different marker/color.
32
+ d_colors : list of str or None, optional
33
+ Colors for external datasets. Cycles if fewer than number of datasets.
34
+ d_markers : list of str or None, optional
35
+ Markers for external datasets. Cycles if fewer than number of datasets.
36
+ legend : bool, default=True
37
+ Whether to display a legend.
38
+ legend_loc : tuple(int, int) or None, optional
39
+ Location of legend in subplot grid, as (row, col). If None, placed on last axis.
40
+ xlim, ylim : tuple or None, optional
41
+ Limits for x and y axes.
42
+
43
+ Returns
44
+ -------
45
+ f : matplotlib.figure.Figure
46
+ Figure object.
47
+ a : np.ndarray of Axes
48
+ Array of Axes objects.
49
+ """
41
50
  if Results is None:
42
- raise Exception("We need some results to work with!")
43
-
44
- if x_axis is None:
45
- x_axis = "MgO"
46
-
47
- if y_axis is None:
48
- y_axis = ["SiO2", "TiO2", "Al2O3", "FeOt", "CaO", "Na2O"]
51
+ raise ValueError("Results cannot be None. Provide MELTS or MAGEMin results dictionary.")
49
52
 
50
- if phase is None:
51
- phase = "liquid1"
53
+ if isinstance(y_axis, str):
54
+ y_axis = (y_axis,)
52
55
 
53
- if line_style is None:
54
- line_style = '-'
55
-
56
- if line_color is None:
57
- line_color = 'k'
56
+ y_axis = list(y_axis)
58
57
 
58
+ # Data loading
59
59
  if data is not None:
60
- if d_color is None:
61
- d_color = 'red'
62
-
63
- if d_marker is None:
64
- d_marker = 'o'
65
-
66
- if type(y_axis) == list:
67
- y_axis = np.array(y_axis)
68
-
69
- if len(y_axis) % 3 == 0.0:
70
- y_axis = y_axis.reshape(len(y_axis)//3, 3)
60
+ if isinstance(data, str):
61
+ if data.contains('.csv'):
62
+ data = pd.read_csv(data)
63
+ else:
64
+ data = pd.read_excel(data)
65
+ elif isinstance(data, dict):
66
+ # validate
67
+ for k, v in data.items():
68
+ if not isinstance(v, pd.DataFrame):
69
+ raise ValueError(f"data['{k}'] must be a DataFrame, got {type(v)}")
70
+
71
+ # -------------------- filter y_axis to only include variables present --------------------
72
+ valid_y = []
73
+ for y in y_axis:
74
+ found = False
75
+
76
+ # Check Results
77
+ for r in (Results.keys() if isinstance(Results, dict) else [Results]):
78
+ Res = Results[r] if isinstance(Results, dict) else Results
79
+ if "All" in Res:
80
+ cols = Res["All"].columns
81
+ if (y + Names[phase]) in cols or y in cols:
82
+ if np.nanmax(Res["All"][y + Names[phase]]) > 0.0:
83
+ found = True
84
+ break
85
+
86
+ # Check data (dict or DataFrame)
87
+ if not found and data is not None:
88
+ if isinstance(data, dict):
89
+ for df in data.values():
90
+ cols = df.columns
91
+ if (y + Names[phase]) in cols or y in cols:
92
+ found = True
93
+ break
94
+ else:
95
+ cols = data.columns
96
+ if (y + Names[phase]) in cols or y in cols:
97
+ found = True
98
+
99
+ if found:
100
+ valid_y.append(y)
101
+
102
+ # Replace y_axis with only valid variables
103
+ y_axis = valid_y
104
+
105
+ # -------------------- subplot grid handling --------------------
106
+ n_vars = len(y_axis)
107
+
108
+ if n_vars == 4: # special case: 2x2 grid
109
+ ncols, nrows = 2, 2
110
+ rows = list(zip_longest(*[iter(y_axis)] * ncols, fillvalue=None))
71
111
  else:
72
- y_axis = np.append(y_axis, np.array(["None"] * (3 - (len(y_axis) % 3))))
73
- y_axis = y_axis.reshape(len(y_axis)//3, 3)
74
-
75
- f, a = plt.subplots(np.shape(y_axis)[0], np.shape(y_axis)[1], figsize = (3.5 * np.shape(y_axis)[1], 3.5 * np.shape(y_axis)[0]))
76
- if 'All' in list(Results.keys()):
77
- if type(phase) == str:
78
- for i in range(np.shape(y_axis)[0]):
79
- for j in range(np.shape(y_axis)[1]):
80
- if y_axis[i,j] != "None":
81
- if data is not None:
82
- a[i][j].plot(data.loc[:,data.columns.str.contains(x_axis)].values, data.loc[:,data.columns.str.contains(y_axis[i,j])].values, d_marker, markerfacecolor = d_color, markeredgecolor = 'k', markersize = 4)
83
-
84
- a[i][j].plot(Results['All'][x_axis + Names[phase]], Results['All'][y_axis[i,j] + Names[phase]], line_style, linewidth = 2, color = line_color)
85
- a[i][j].set_ylabel(y_axis[i][j] + " wt%")
86
- if i != np.shape(y_axis)[0] - 1:
87
- if i == np.shape(y_axis)[0] - 2:
88
- if y_axis[i+1,j] == "None":
89
- a[i][j].set_xlabel(x_axis + " wt%")
90
- else:
91
- a[i][j].set_xlabel(x_axis + " wt%")
92
-
93
- else:
94
- a[i][j].axis('off')
112
+ ncols = 3
113
+ nrows = int(np.ceil(n_vars / ncols))
114
+ rows = list(zip_longest(*[iter(y_axis)] * ncols, fillvalue=None))
115
+
116
+ # scale figure size dynamically
117
+ fig_width = 3.2 * ncols
118
+ fig_height = 3.0 * nrows
119
+ f, a = plt.subplots(nrows, ncols, figsize=(fig_width, fig_height))
120
+ a = np.atleast_2d(a)
121
+
122
+ # Color handling for models
123
+ color_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]
124
+ if line_color is None:
125
+ def pick_color(i): return color_cycle[i % len(color_cycle)]
126
+ else:
127
+ def pick_color(i): return line_color
128
+
129
+ # Color/marker cycles for user data
130
+ d_color_cycle = d_color if d_color else color_cycle
131
+ d_marker_cycle = d_marker if d_marker else ["o", "s", "^", "D", "v", "P", "*"]
132
+
133
+ # -------------------- helper functions --------------------
134
+ def plot_panel(ax, x_var, y_var, idx=None, Res = None):
135
+ """Plot one panel (model + data) on given Axes."""
136
+ suffix = Names[phase] if phase in Names else ""
137
+ xcol, ycol = x_var + suffix, y_var + suffix
138
+
139
+ # plot model data
140
+ if Res is not None:
141
+ if xcol in Results[Res]["All"] and ycol in Results[Res]["All"]:
142
+ x = Results[Res]["All"][xcol].values
143
+ y = Results[Res]["All"][ycol].values
144
+ ax.plot(x, y, color=pick_color(idx), label=Res)
145
+ else:
146
+ if xcol in Results["All"] and ycol in Results["All"]:
147
+ x = Results["All"][xcol].values
148
+ y = Results["All"][ycol].values
149
+ ax.plot(x, y, color='k')
150
+
151
+ def plot_data(ax, x_var, y_var):
152
+ # plot external data
153
+ suffix = Names[phase] if phase in Names else ""
154
+ xcol, ycol = x_var + suffix, y_var + suffix
155
+
156
+ if data is not None:
157
+ if isinstance(data, pd.DataFrame):
158
+ datasets = {"data": data}
159
+ else:
160
+ datasets = data
161
+
162
+ for k, df in datasets.items():
163
+ c = d_color_cycle[list(datasets.keys()).index(k) % len(d_color_cycle)]
164
+ m = d_marker_cycle[list(datasets.keys()).index(k) % len(d_marker_cycle)]
165
+
166
+ # try with suffix first
167
+ if xcol in df.columns:
168
+ dx = df[xcol].values
169
+ elif x_var in df.columns:
170
+ dx = df[x_var].values
171
+ else:
172
+ dx = None
173
+ print(f"x axis variable {x_var} not found in data")
95
174
 
175
+ if ycol in df.columns:
176
+ dy = df[ycol].values
177
+ elif y_var in df.columns:
178
+ dy = df[y_var].values
179
+ else:
180
+ dy = None
181
+ print(f"y axis variable {y_var} not found in data")
182
+
183
+ if dx is not None and dy is not None:
184
+ ax.plot(dx, dy, m,
185
+ markerfacecolor=c,
186
+ markeredgecolor="k",
187
+ markersize=4,
188
+ linestyle="None",
189
+ label=k)
190
+
191
+ ax.set(xlabel=x_var, ylabel=y_var)
192
+
193
+ if xlim:
194
+ ax.set_xlim(xlim)
195
+ if ylim:
196
+ ax.set_ylim(ylim)
197
+
198
+ # -------------------- main plotting --------------------
199
+ for i, row in enumerate(rows):
200
+ for j, y in enumerate(row):
201
+ if y is None:
202
+ a[i, j].axis("off")
203
+ continue
204
+ if 'All' in Results.keys():
205
+ if data is not None:
206
+ plot_data(a[i,j], x_axis, y)
207
+ plot_panel(a[i,j], x_axis, y)
208
+ else:
209
+ if data is not None:
210
+ plot_data(a[i,j], x_axis, y)
211
+ for idx, Res in enumerate(Results):
212
+ plot_panel(a[i, j], x_axis, y, idx=idx, Res = Res)
213
+
214
+ # -------------------- legend --------------------
215
+ empty_axes = []
216
+ for i in range(a.shape[0]):
217
+ for j in range(a.shape[1]):
218
+ if i * a.shape[1] + j >= len(y_axis): # beyond valid y variables
219
+ empty_axes.append(a[i][j])
220
+
221
+ handles, labels = a[0][0].get_legend_handles_labels()
222
+ if empty_axes and handles:
223
+ empty_axes[0].legend(handles, labels, loc = "center")
224
+ empty_axes[0].axis("off")
96
225
  else:
97
- if type(phase) == str:
98
- for i in range(np.shape(y_axis)[0]):
99
- for j in range(np.shape(y_axis)[1]):
100
- if y_axis[i,j] != "None":
101
- if data is not None:
102
- a[i][j].plot(data.loc[:,data.columns.str.contains(x_axis)], data.loc[:,data.columns.str.contains(y_axis[i,j])], d_marker, markerfacecolor = d_color, markeredgecolor = 'k', markersize = 4, label = "Data")
103
-
104
- a[i][j].set_ylabel(y_axis[i][j] + " wt%")
105
- if i != np.shape(y_axis)[0] - 1:
106
- if i == np.shape(y_axis)[0] - 2:
107
- if y_axis[i+1,j] == "None":
108
- a[i][j].set_xlabel(x_axis + " wt%")
109
- else:
110
- a[i][j].set_xlabel(x_axis + " wt%")
111
-
112
- else:
113
- a[i][j].axis('off')
114
-
115
- for r in Results:
116
- Res = Results[r].copy()
117
- if type(phase) == str:
118
- for i in range(np.shape(y_axis)[0]):
119
- for j in range(np.shape(y_axis)[1]):
120
- if y_axis[i,j] != "None":
121
- a[i][j].plot(Res['All'][x_axis + Names[phase]], Res['All'][y_axis[i,j] + Names[phase]], line_style, linewidth = 2, label = r)
122
-
123
- if label is not None:
124
- a[0][0].legend()
226
+ if legend:
227
+ if legend_loc is None:
228
+ loc_i, loc_j = len(rows) - 1, 0
229
+ else:
230
+ loc_i, loc_j = legend_loc
231
+ a[loc_i, loc_j].legend()
125
232
 
126
233
  f.tight_layout()
127
234
 
235
+ return f, a
236
+
128
237
  def plot_surfaces(Results = None, P_bar = None, phases = None, H2O_Liq = None):
129
238
  if H2O_Liq is None:
130
239
  f, a = plt.subplots(1,1, figsize = (5,4))
@@ -358,69 +467,191 @@ def residualT_plot(Results = None, P_bar = None, phases = None, H2O_Liq = None,
358
467
  a[i][j].plot_surface(X_new, Y_new, z_plot, cmap = 'viridis')
359
468
  a[i][j].set_zlim([0,50])
360
469
 
361
- def phase_plot(Results = None, y_axis = None, x_axis = None,
362
- phases = ['Liq', 'Ol', 'Opx', 'Cpx', 'Sp', 'Grt'], cmap = "Reds",
363
- title = None, figsize = None):
364
-
365
- if type(Results) != list:
366
- f, a = plt.subplots(1,1, figsize = (5, 8))
367
- c = cm.get_cmap(cmap, len(phases))
368
- x = c(np.arange(0,1,1/len(phases)))
369
470
 
370
- PhaseList = {}
371
- for idx, p in enumerate(phases):
372
- PhaseList[p] = x[idx]
373
-
374
- Stop = np.zeros(len(Results['All']['P_bar']))
375
- for idx, p in enumerate(phases):
376
- if 'mass_' + p in Results['All'].keys():
377
- a.fill_betweenx(Results['All']['P_bar'], Stop,
378
- x2= Stop + Results['All']['mass_' + p], alpha = 0.75, color = PhaseList[p], lw = 0)
379
-
380
- Stop = Stop + Results['All']['mass_' + p]
381
-
382
- a.set_ylabel('Pressure (bars)')
383
- a.set_xlabel('Mass (g)')
384
-
385
- a.set_xlim([0,np.nanmax(Stop)])
386
- a.set_ylim([np.nanmax(Results['All']['P_bar']), np.nanmin(Results['All']['P_bar'])])
387
- else:
388
- if figsize is None:
389
- figsize = (10,2*len(Results))
390
-
391
- f, a = plt.subplots(len(Results), 1, figsize = figsize, sharex = True)
471
+ def phase_plot(Results, x_axis = None, y_axis = None, cmap = "Reds"):
472
+ """
473
+ Create stacked phase mass-fraction plots from thermodynamic model results.
474
+
475
+ This function generates diagrams of phase proportions from the results of
476
+ crystallization or melting simulations. Mass fractions of crystalline and
477
+ liquid phases are stacked either along the x-axis or y-axis, depending on
478
+ user specification, to visualize how phase proportions evolve with pressure,
479
+ temperature, or another variable.
480
+
481
+ Parameters
482
+ ----------
483
+ Results : dict
484
+ Dictionary containing model outputs. It should have one of the following structures:
485
+ - **Single-run results**:
486
+ ```
487
+ Results = {
488
+ "Mass": pandas.DataFrame, # columns = phase names or phase_cumsum
489
+ "All": pandas.DataFrame # contains the axis variable (e.g., T_C, P_bar)
490
+ }
491
+ ```
492
+ - **Multi-run results**:
493
+ ```
494
+ Results = {
495
+ run_label: {
496
+ "Mass": pandas.DataFrame,
497
+ "All": pandas.DataFrame
498
+ },
499
+ ...
500
+ }
501
+ ```
502
+
503
+ The `"Mass"` DataFrame must include mass fractions for each phase
504
+ (either raw values or cumulative values with `"_cumsum"` suffix).
505
+ The `"All"` DataFrame must contain the column specified by `x_axis` or `y_axis`.
506
+
507
+ x_axis : str, optional
508
+ Column name in `Results["All"]` (or equivalent) to plot on the x-axis.
509
+ If provided, `y_axis` must be `None`. Typically something like `"T_C"`.
510
+
511
+ y_axis : str, optional
512
+ Column name in `Results["All"]` (or equivalent) to plot on the y-axis.
513
+ If provided, `x_axis` must be `None`. Typically `"P_bar"`.
514
+
515
+ cmap : str, default = "Reds"
516
+ Matplotlib colormap used to assign colors to phases.
517
+
518
+ Returns
519
+ -------
520
+ fig : matplotlib.figure.Figure or list of Figures
521
+ - If `Results` contains a single run: a single `Figure`.
522
+ - If `Results` contains multiple runs: a list of `Figure` objects, one per run.
523
+
524
+ axes : matplotlib.axes.Axes or list of Axes
525
+ - If `Results` contains a single run: a single `Axes` object.
526
+ - If `Results` contains multiple runs: a list of `Axes` objects, one per run.
527
+
528
+ Notes
529
+ -----
530
+ - If both `x_axis` and `y_axis` are provided, the function raises a `ValueError`.
531
+ - Phases are automatically ordered:
532
+ 1. By the index where they first appear (highest pressure or temperature).
533
+ 2. By the order specified in the petthermotools dictionaries `Names` and `Names_MM`,
534
+ if available.
535
+ - The liquid phase (`"liq1"` or `"liquid1"`) is always plotted last.
536
+ - A legend with readable phase labels is added outside
537
+ the plot area.
538
+
539
+ Examples
540
+ --------
541
+ >>> fig, ax = phase_plot(Results, y_axis="P_bar")
542
+ # Plots stacked phase proportions vs. pressure
543
+
544
+ >>> fig, axes = phase_plot(MultiRunResults, x_axis="T_C")
545
+ # Creates one stacked phase plot per run, vs. temperature
546
+ """
547
+ if x_axis is not None and y_axis is not None:
548
+ raise ValueError("Please provide either a x-axis or y-axis parameter to plot the mass fractions against")
549
+
550
+ def makeplot(Mass, Res, title = None):
551
+ # --- Identify whether _cumsum columns exist ---
552
+ use_cumsum = any(col.endswith("_cumsum") for col in Mass.columns)
553
+ suffix = "_cumsum" if use_cumsum else ""
554
+
555
+ # --- Identify phases ---
556
+ exclude_cols = {'T_C', None}
557
+ phases = [
558
+ col.replace(suffix, "")
559
+ for col in Mass.columns
560
+ if col.endswith(suffix) or (not use_cumsum and not col.endswith("_cumsum") and col not in exclude_cols)
561
+ ]
562
+
563
+ # --- Handle liquid phase ---
564
+ liquid_name = None
565
+ for liq_name in ["liq1", "liquid1"]:
566
+ if liq_name in Mass.columns:
567
+ liquid_name = liq_name
568
+ if liq_name in phases:
569
+ phases.remove(liq_name)
570
+ break # use the first match
571
+
572
+ # --- Create dictionary priority (order in Names/Names_MM) ---
573
+ phase_priority = {}
574
+ for order_dict in [Names, Names_MM]:
575
+ for i, key in enumerate(order_dict.keys()):
576
+ phase_priority[key] = i # smaller i = higher priority in tie
577
+
578
+ # --- Sort crystalline phases ---
579
+ phase_first_index = {}
580
+ for p in phases:
581
+ col = p + suffix if (use_cumsum and p + suffix in Mass.columns) else p
582
+ vals = Mass[col].values
583
+ nz = np.flatnonzero(vals > 0)
584
+ phase_first_index[p] = nz[0] if len(nz) > 0 else np.inf
585
+
586
+ def sort_key(p):
587
+ return (phase_first_index[p], phase_priority.get(p, 1e6))
588
+
589
+ phases = sorted(phases, key=sort_key)
590
+
591
+ # --- Append liquid last ---
592
+ if liquid_name is not None:
593
+ phases.append(liquid_name)
594
+
595
+ # --- Assign colors ---
392
596
  c = cm.get_cmap(cmap, len(phases))
393
- x = c(np.arange(0,1,1/len(phases)))
394
-
395
- PhaseList = {}
396
- for idx, p in enumerate(phases):
397
- PhaseList[p] = x[idx]
597
+ PhaseColors = {p: c(i) for i, p in enumerate(phases)}
398
598
 
399
- for i in range(len(Results)):
400
- Stop = np.zeros(len(Results[i]['All']['P_bar']))
401
- for idx, p in enumerate(phases):
402
- a[i].fill_between(Results[i]['All']['P_bar'], Stop,
403
- y2= Stop + Results[i]['All']['mass_' + p], alpha = 0.75, color = PhaseList[p], lw = 0,
404
- label = p)
599
+ # --- Setup figure ---
600
+ if y_axis == "P_bar":
601
+ f, a = plt.subplots(figsize=(4, 6))
602
+ else:
603
+ f, a = plt.subplots(figsize=(4, 3.5))
604
+
605
+ # --- Determine orientation ---
606
+ coord = Res[y_axis] if y_axis else Res[x_axis]
607
+ horizontal = y_axis is not None
608
+
609
+ Stop = np.zeros(len(Mass))
610
+ for p in phases:
611
+ col = p + suffix if use_cumsum else p
612
+ vals = Mass[col].values
613
+
614
+ # --- Legend label mapping ---
615
+ label = Names.get(p, Names_MM.get(p, p))[1:]
616
+
617
+ if horizontal:
618
+ a.fill_betweenx(coord, Stop, Stop + vals, color=PhaseColors[p], alpha=0.75, lw=0, label=label)
619
+ else:
620
+ a.fill_between(coord, Stop, Stop + vals, color=PhaseColors[p], alpha=0.75, lw=0, label=label)
621
+ Stop += vals
622
+
623
+ # --- Labels ---
624
+ if horizontal:
625
+ a.set_ylabel(y_axis)
626
+ a.set_xlabel("Mass fraction")
627
+ else:
628
+ a.set_xlabel(x_axis)
629
+ a.set_ylabel("Mass fraction")
405
630
 
406
- Stop = Stop + Results[i]['All']['mass_'+p]
631
+ if title is not None:
632
+ a.set_title(title)
407
633
 
408
- if i == len(Results)-1:
409
- a[i].set_xlabel('Pressure (bars)')
410
- a[i].legend(loc = "lower right")
411
- a[i].set_ylabel('Mass Fraction')
634
+ # --- Remove whitespace ---
635
+ a.margins(0)
636
+ f.tight_layout()
412
637
 
413
- a[i].set_ylim([0,np.nanmax(Stop)])
414
- a[i].set_xlim([np.nanmin(Results[i]['All']['P_bar']), np.nanmax(Results[i]['All']['P_bar'])])
638
+ a.legend(loc="center left", bbox_to_anchor=(1.02, 0.5))
415
639
 
640
+ return f, a
641
+ fig = []
642
+ axes = []
416
643
 
417
- if title is not None:
418
- if type(title) != list:
419
- a[i].set_title(title)
420
- else:
421
- a[i].set_title(title[i])
644
+ if 'All' in Results.keys():
645
+ fig, axes = makeplot(Results['Mass'], Results['All'])
646
+ else:
647
+ for val in Results.keys():
648
+ f, a = makeplot(Results[val]['Mass'],
649
+ Results[val]['All'],
650
+ title = val)
651
+ fig.append(f)
652
+ axes.append(a)
653
+ return fig, axes
422
654
 
423
- return f, a
424
655
 
425
656
  def plot_phaseDiagram(Model = "Holland", Combined = None, P_units = "bar", T_units = "C",
426
657
  lines = None, T_C = None, P_bar = None, label = True, colormap = None):
@@ -412,6 +412,11 @@ def saturation_pressure(Model = "MELTSv1.2.0", cores = multiprocessing.cpu_count
412
412
  if fO2_buffer != "FMQ":
413
413
  raise Warning("fO2 buffer specified is not an allowed input. This argument can only be 'FMQ' or 'NNO' \n if you want to offset from these buffers use the 'fO2_offset' argument.")
414
414
 
415
+ if "MELTS" not in Model:
416
+ if fO2_buffer == "FMQ":
417
+ fO2_buffer = "qfm"
418
+ if fO2_buffer == "NNO":
419
+ fO2_buffer = "nno"
415
420
  # ensure the bulk composition has the correct headers etc.
416
421
  comp = comp_fix(Model = Model, comp = comp, Fe3Fet_Liq = Fe3Fet_init, H2O_Liq = H2O_init, CO2_Liq = CO2_init)
417
422
 
@@ -5,4 +5,4 @@
5
5
  # 1) we don't load dependencies by storing it in __init__.py
6
6
  # 2) we can import it in setup.py for the same reason
7
7
  # 3) we can import it into your module
8
- __version__ = '0.2.41'
8
+ __version__ = '0.2.42'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PetThermoTools
3
- Version: 0.2.41
3
+ Version: 0.2.42
4
4
  Summary: PetThermoTools
5
5
  Home-page: https://github.com/gleesonm1/PetThermoTools
6
6
  Author: Matthew Gleeson