Skip to content

Commit a6f4dd0

Browse files
authored
Merge pull request #695 from bobmyhill/avPT_w_free_c_vectors
Allow free compositional vectors in optimal thermobarometry problems
2 parents 28b409a + e1040e9 commit a6f4dd0

File tree

8 files changed

+301
-55
lines changed

8 files changed

+301
-55
lines changed

burnman/classes/solution.py

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -131,41 +131,62 @@ def formula(self):
131131
@material_property
132132
def site_occupancies(self):
133133
"""
134-
:returns: The fractional occupancies of species on each site.
135-
:rtype: list of OrderedDicts
134+
:returns: The fractional occupancies of species on each site
135+
as a flat vector.
136+
:rtype: numpy.ndarray
136137
"""
137138
f = self.molar_fractions
138-
occs = np.einsum("ij, i", self.solution_model.endmember_occupancies, f)
139-
site_occs = []
140-
k = 0
141-
for site in self.solution_model.sites:
142-
site_occs.append(OrderedDict())
143-
for species in site:
144-
site_occs[-1][species] = occs[k]
145-
k += 1
146-
147-
return site_occs
139+
occs = f.dot(self.solution_model.endmember_occupancies)
140+
return occs
148141

149-
def site_formula(self, precision=2):
142+
def site_formula(
143+
self,
144+
precision=2,
145+
print_absent_species=True,
146+
site_occupancies=None,
147+
):
150148
"""
151149
Returns the molar chemical formula of the solution with
152150
site occupancies. For example, [Mg0.4Fe0.6]2SiO4.
153151
154152
:param precision: Precision with which to print the site occupancies
155153
:type precision: int
156154
155+
:param print_absent_species: If True, prints site occupancies that are absent (i.e., zero).
156+
:type print_absent_species: bool
157+
158+
:param site_occupancies: Optional site occupancies to use instead of
159+
the current ones.
160+
:type site_occupancies: numpy.ndarray
161+
157162
:returns: Molar chemical formula of the solution with site occupancies
158163
:rtype: str
159164
"""
160-
split_empty = self.solution_model.empty_formula.split("[")
161-
formula = split_empty[0]
162-
for i, site_occs in enumerate(self.site_occupancies):
165+
if site_occupancies is not None:
166+
occs = site_occupancies
167+
else:
168+
occs = self.site_occupancies
169+
170+
i = 0
171+
# Split the empty formula into site sections.
172+
# This allows us to reconstruct the formula including
173+
# occupancies and anything outside the sites.
174+
split_formula = self.solution_model.empty_formula.split("[")
175+
formula = split_formula[0]
176+
for j, site in enumerate(self.solution_model.sites):
163177
formula += "["
164-
for species, occ in site_occs.items():
165-
if np.abs(occ) < 1.0e-12:
166-
occ = np.abs(occ)
178+
for species in site:
179+
# avoid printing -0.00
180+
occ = occs[i] if np.abs(occs[i]) >= 1.0e-12 else 0.0
181+
182+
# skip absent species if requested
183+
if np.abs(occ) < 1.0e-12 and not print_absent_species:
184+
i += 1
185+
continue
167186
formula += f"{species}{occ:0.{precision}f}"
168-
formula += split_empty[i + 1]
187+
i += 1
188+
formula += split_formula[j + 1]
189+
169190
return formula
170191

171192
@material_property

burnman/tools/thermobarometry.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ def estimate_conditions(
107107
for a given mineral assemblage. Algorithm based on Powell and Holland (1994).
108108
109109
:param assemblage: The mineral assemblage for which to perform the inversion.
110+
Each solution phase in the assemblage must have its composition set along with
111+
its compositional covariance matrix (called `compositional_covariances`).
112+
If there are compositional degrees of freedom, they can be added by setting
113+
the attribute `free_compositional_vectors` on the relevant solution phases, where each
114+
row of the array corresponds to a free compositional vector, and the columns correspond
115+
to the amounts of endmembers of that phase in each vector.
110116
:type assemblage: Assemblage
111117
112118
:param dataset_covariances: The covariance data from the thermodynamic dataset.
@@ -146,13 +152,21 @@ def estimate_conditions(
146152
and the fit quality (fit, given by the square root of the reduced chi-squared value).
147153
:rtype: OptimizeResult
148154
"""
149-
# TODO: Implement compositional unknown parameters like redox state
150-
# to join P and T in the optimization
151155

156+
# Count the number of free compositional vectors across all phases
157+
n_free_vectors = 0
158+
for phase in assemblage.phases:
159+
if hasattr(phase, "free_compositional_vectors"):
160+
n_free_vectors += phase.free_compositional_vectors.shape[0]
161+
phase.baseline_composition = phase.molar_fractions.copy()
162+
163+
# Check if P and/or T are fixed
152164
P_fixed = np.isclose(pressure_bounds[0], pressure_bounds[1])
153165
T_fixed = np.isclose(temperature_bounds[0], temperature_bounds[1])
154-
if P_fixed and T_fixed:
155-
raise Exception("Both pressure and temperature cannot be fixed!")
166+
if P_fixed and T_fixed and n_free_vectors == 0:
167+
raise Exception(
168+
"Both pressure and temperature cannot be fixed if there are no free compositional vectors!"
169+
)
156170

157171
# Build the reaction matrix R, possibly reducing the number of endmembers
158172
# by excluding components with small molar fractions
@@ -189,7 +203,18 @@ def estimate_conditions(
189203
raise Exception("No independent reactions found for the given assemblage!")
190204

191205
def calculate_cov_a(assemblage, theta, dataset_covariances):
192-
assemblage.set_state(*theta)
206+
if n_free_vectors > 0:
207+
vi = 2
208+
for phase in assemblage.phases:
209+
if hasattr(phase, "free_compositional_vectors"):
210+
n_free_vectors_in_phase = phase.free_compositional_vectors.shape[0]
211+
v = np.array(theta[vi : vi + n_free_vectors_in_phase])
212+
dc = phase.free_compositional_vectors.T.dot(v)
213+
new_composition = phase.baseline_composition + dc
214+
phase.set_composition(new_composition)
215+
vi += n_free_vectors_in_phase
216+
217+
assemblage.set_state(*theta[:2])
193218
cov1 = _build_endmember_covariance_matrix(assemblage, dataset_covariances)
194219
cov2 = _build_solution_covariance_matrix(assemblage)
195220
cov_mu = cov1 + cov2
@@ -207,18 +232,36 @@ def chisqr(args):
207232
"""
208233
# These are the chemical potential covariances for all endmembers
209234
cov_a = calculate_cov_a(
210-
assemblage, [args[0] * P_scaling, args[1]], dataset_covariances
235+
assemblage, [args[0] * P_scaling, *args[1:]], dataset_covariances
211236
)
212237
a = R.dot(assemblage.endmember_partial_gibbs)
213-
chisqr = a.T.dot(np.linalg.inv(cov_a)).dot(a)
238+
chisqr = a.T.dot(np.linalg.pinv(cov_a)).dot(a)
214239
return chisqr
215240

216241
# Minimize the chi-squared function
217-
x0 = [guessed_conditions[0] / P_scaling, guessed_conditions[1]]
242+
x0 = [
243+
guessed_conditions[0] / P_scaling,
244+
guessed_conditions[1],
245+
*[0.0] * n_free_vectors,
246+
]
218247
bounds = [
219248
(pressure_bounds[0] / P_scaling, pressure_bounds[1] / P_scaling),
220249
(temperature_bounds[0], temperature_bounds[1]),
250+
*[(None, None)] * n_free_vectors,
221251
]
252+
253+
if n_free_vectors > 0:
254+
# Nelder-Mead is more robust for problems where the feasible region
255+
# may be complex due to compositional degrees of freedom
256+
res = minimize(
257+
chisqr,
258+
x0,
259+
method="Nelder-Mead",
260+
bounds=bounds,
261+
options={"maxiter": max_it},
262+
)
263+
x0 = res.x
264+
222265
res = minimize(
223266
chisqr,
224267
x0,

burnman/utils/chemistry.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,20 @@ def ordered_compositional_array(formulae, elements):
561561
return formula_array
562562

563563

564-
def formula_to_string(formula):
564+
def formula_to_string(formula, use_fractions=True, significant_figures=2):
565565
"""
566566
:param formula: Chemical formula
567567
:type formula: dict or Counter
568568
569+
:param use_fractions: Whether or not to use fractions in the output string.
570+
If False, numbers are rounded to the number of significant figures
571+
given by the significant_figures parameter.
572+
:type use_fractions: bool
573+
574+
:param significant_figures: Number of significant figures to use if
575+
use_fractions=False.
576+
:type significant_figures: int
577+
569578
:returns: A formula string, with element order as given in the list
570579
IUPAC_element_order.
571580
If one or more keys in the dictionary are not one of the elements
@@ -574,21 +583,40 @@ def formula_to_string(formula):
574583
"""
575584

576585
formula_string = ""
577-
for e in IUPAC_element_order:
578-
if e in formula and np.abs(formula[e]) > 1.0e-12:
579-
if np.abs(formula[e] - 1.0) < 1.0e-12:
580-
formula_string += e
581-
else:
582-
formula_string += e + str(nsimplify(formula[e]))
583-
584-
for e in formula:
585-
if e not in IUPAC_element_order:
586+
if use_fractions:
587+
for e in IUPAC_element_order:
586588
if e in formula and np.abs(formula[e]) > 1.0e-12:
587589
if np.abs(formula[e] - 1.0) < 1.0e-12:
588590
formula_string += e
589591
else:
590592
formula_string += e + str(nsimplify(formula[e]))
591593

594+
for e in formula:
595+
if e not in IUPAC_element_order:
596+
if e in formula and np.abs(formula[e]) > 1.0e-12:
597+
if np.abs(formula[e] - 1.0) < 1.0e-12:
598+
formula_string += e
599+
else:
600+
formula_string += e + str(nsimplify(formula[e]))
601+
602+
else:
603+
for e in IUPAC_element_order:
604+
if e in formula and np.abs(formula[e]) > 1.0e-12:
605+
if np.abs(formula[e] - 1.0) < 1.0e-12:
606+
formula_string += e
607+
else:
608+
formula_string += e + str(round(formula[e], significant_figures))
609+
610+
for e in formula:
611+
if e not in IUPAC_element_order:
612+
if e in formula and np.abs(formula[e]) > 1.0e-12:
613+
if np.abs(formula[e] - 1.0) < 1.0e-12:
614+
formula_string += e
615+
else:
616+
formula_string += e + str(
617+
round(formula[e], significant_figures)
618+
)
619+
592620
return formula_string
593621

594622

examples/example_optimal_thermobarometry.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from burnman.minerals import mp50MnNCKFMASHTO, HP_2011_ds62
3838
from burnman.optimize.composition_fitting import fit_composition_to_solution
3939
from burnman.tools.thermobarometry import estimate_conditions
40+
from burnman.utils.chemistry import formula_to_string
4041

4142
if __name__ == "__main__":
4243

@@ -70,7 +71,9 @@
7071
mu.compositional_covariances = pcov
7172

7273
# b) Biotite (requires parameter constraining order state of
73-
# Mg and Fe on the first two sites)
74+
# Mg and Fe on the first two sites).
75+
# Here, we will allow the Mg-Fe ordering to vary to improve the fit
76+
# by adding a free compositional vector.
7477
fitted_species = ["Mn", "Fe", "Mg", "Al", "Si", "Ti", "Mg_M3"]
7578
species_amounts = np.array([0.01, 1.50, 1.00, 1.65, 2.65, 0.20, 0.10])
7679
species_covariances = np.diag(
@@ -82,6 +85,7 @@
8285
)
8386
bi.set_composition(popt)
8487
bi.compositional_covariances = pcov
88+
bi.free_compositional_vectors = Composite([bi]).reaction_basis
8589

8690
# c) Garnet
8791
fitted_species = ["Mn", "Fe", "Mg", "Ca", "Al", "Si"]
@@ -94,6 +98,8 @@
9498
g.compositional_covariances = pcov
9599

96100
# d) Ilmenite (requires parameter constraining order state of Fe and Ti)
101+
# Here, we will allow the Fe-Ti ordering to vary to improve the fit
102+
# by adding a free compositional vector.
97103
fitted_species = ["Mn", "Fe", "Ti", "Mg", "Fe2+_A"]
98104
species_amounts = np.array([0.05, 1.0, 0.90, 0.05, 0.4])
99105
species_covariances = np.diag(np.array([0.01, 0.01, 0.01, 0.01, 0.2]) ** 2)
@@ -103,6 +109,7 @@
103109
)
104110
ilmm.set_composition(popt)
105111
ilmm.compositional_covariances = pcov
112+
ilmm.free_compositional_vectors = Composite([ilmm]).reaction_basis
106113

107114
# e) Staurolite
108115
fitted_species = ["Mn", "Fe", "Mg", "Al", "Si", "Ti"]
@@ -128,20 +135,74 @@
128135

129136
# 4) Print the estimated conditions along with their uncertainties
130137
# and correlations.
138+
print("Results of thermobarometric inversion:")
131139
print(
132-
f"Estimated Pressure: {assemblage.pressure/1.e9:.2f} "
140+
f" Estimated Pressure: {assemblage.pressure/1.e9:.2f} "
133141
f"+/- {np.sqrt(res.xcov[0, 0])/1.e9:.2f} GPa"
134142
)
135143
print(
136-
f"Estimated Temperature: {assemblage.temperature:.0f} "
144+
f" Estimated Temperature: {assemblage.temperature:.0f} "
137145
f"+/- {np.sqrt(res.xcov[1, 1]):.0f} K"
138146
)
139-
print(f"Correlation between P and T: {res.xcorr:.2f}")
140-
print(f"Number of Reactions: {res.n_reactions}")
141-
print(f"Number of Parameters: {res.n_params}")
142-
print(f"Degrees of Freedom: {res.degrees_of_freedom}")
143-
print(f"Reduced Chi-squared: {res.reduced_chisqr:.2f}")
144-
print(f"Fit (sqrt reduced chi-squared): {res.fit:.2f}")
147+
print(f" Correlation between P and T: {res.xcorr:.2f}")
148+
print(f" Number of Reactions: {res.n_reactions}")
149+
print(f" Number of Parameters: {res.n_params}")
150+
print(f" Degrees of Freedom: {res.degrees_of_freedom}")
151+
print(f" Reduced Chi-squared: {res.reduced_chisqr:.2f}")
152+
print(f" Fit (sqrt reduced chi-squared): {res.fit:.2f}")
153+
154+
# The names of the sites in these models are a bit cumbersome,
155+
# so we will do some string replacements to make them more
156+
# readable in the output.
157+
replace_strings = [
158+
["Fethree", "Fe3+"],
159+
["monetwo", ""],
160+
["mtwoa", ""],
161+
["mtwob", ""],
162+
["mthree", ""],
163+
["x", ""],
164+
["y", ""],
165+
["tone", ""],
166+
["t", ""],
167+
["Ka", "K"],
168+
["Naa", "Na"],
169+
["Caa", "Ca"],
170+
["Fea", "Fe"],
171+
["Mga", "Mg"],
172+
["Mna", "Mn"],
173+
["Tia", "Ti"],
174+
["Fe3+a", "Fe3+"],
175+
["Oh", "(OH)"],
176+
["b", ""],
177+
["v", ""],
178+
]
179+
180+
print("\nMineral compositions and site occupancies:")
181+
for phase in assemblage.phases:
182+
np.set_printoptions(precision=2, formatter={"all": lambda x: f"{x:0.2f}"})
183+
print(f"{phase.name}:")
184+
185+
if hasattr(phase, "free_compositional_vectors"):
186+
free_site_occupancy_vectors = phase.free_compositional_vectors.dot(
187+
phase.solution_model.endmember_occupancies
188+
)
189+
for v in free_site_occupancy_vectors:
190+
site_formula = phase.site_formula(
191+
precision=2, site_occupancies=v, print_absent_species=False
192+
)
193+
for old, new in replace_strings:
194+
site_formula = site_formula.replace(old, new)
195+
print(f" free site occupancy vector: {site_formula}")
196+
197+
print(
198+
f" composition: {formula_to_string(phase.formula, use_fractions=False)}"
199+
)
200+
if hasattr(phase, "molar_fractions"):
201+
site_formula = phase.site_formula(2)
202+
for old, new in replace_strings:
203+
site_formula = site_formula.replace(old, new)
204+
print(f" site occupancies: {site_formula}")
205+
print("")
145206

146207
# Uncomment to see the weighted reaction affinities
147208
# print("Weighted reaction affinities:")

0 commit comments

Comments
 (0)