Skip to content

Commit 8c11b02

Browse files
authored
Merge pull request #693 from bobmyhill/optimal_pt
added optimal PT calculations
2 parents 4ac4257 + e06fa8b commit 8c11b02

File tree

12 files changed

+791
-11
lines changed

12 files changed

+791
-11
lines changed

burnman/classes/composite.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,40 @@ def endmember_partial_gibbs(self):
473473
j += n_endmembers
474474
return partial_gibbs
475475

476+
@material_property
477+
def endmember_partial_entropies(self):
478+
"""
479+
Returns the partial entropies for all
480+
the endmember minerals in the Composite
481+
"""
482+
partial_entropies = np.empty(self.n_endmembers)
483+
j = 0
484+
for i, n_endmembers in enumerate(self.endmembers_per_phase):
485+
if n_endmembers == 1:
486+
partial_entropies[j] = self.phases[i].molar_entropy
487+
else:
488+
partial_entropies[j : j + n_endmembers] = self.phases[
489+
i
490+
].partial_entropies
491+
j += n_endmembers
492+
return partial_entropies
493+
494+
@material_property
495+
def endmember_partial_volumes(self):
496+
"""
497+
Returns the partial volumes for all
498+
the endmember minerals in the Composite
499+
"""
500+
partial_volumes = np.empty(self.n_endmembers)
501+
j = 0
502+
for i, n_endmembers in enumerate(self.endmembers_per_phase):
503+
if n_endmembers == 1:
504+
partial_volumes[j] = self.phases[i].molar_volume
505+
else:
506+
partial_volumes[j : j + n_endmembers] = self.phases[i].partial_volumes
507+
j += n_endmembers
508+
return partial_volumes
509+
476510
@material_property
477511
def reaction_affinities(self):
478512
"""

burnman/optimize/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This file is part of BurnMan - a thermoelastic and thermodynamic toolkit for
22
# the Earth and Planetary Sciences
3-
# Copyright (C) 2012 - 2021 by the BurnMan team, released under the GNU
3+
# Copyright (C) 2012 - 2025 by the BurnMan team, released under the GNU
44
# GPL v2 or later.
55

66
"""

burnman/optimize/linear_fitting.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import importlib
88
import numpy as np
9-
from scipy.linalg import inv, sqrtm
9+
from scipy.linalg import sqrtm
1010
import warnings
1111

1212
try:
@@ -18,7 +18,12 @@
1818

1919

2020
def weighted_constrained_least_squares(
21-
A, b, Cov_b=None, equality_constraints=None, inequality_constraints=None
21+
A,
22+
b,
23+
Cov_b=None,
24+
equality_constraints=None,
25+
inequality_constraints=None,
26+
allow_rank_deficient=False,
2227
):
2328
"""
2429
Solves a weighted, constrained least squares problem using cvxpy.
@@ -45,6 +50,10 @@ def weighted_constrained_least_squares(
4550
in the objective function above.
4651
:type inequality_constraints: list containing a 2D array and 1D array
4752
53+
:param allow_rank_deficient: If True, allows the problem to be solved
54+
even if the design matrix is rank-deficient.
55+
:type allow_rank_deficient: bool
56+
4857
:returns: Tuple containing the optimized phase amounts (1D numpy.array),
4958
a covariance matrix corresponding to the optimized phase amounts
5059
(2D numpy.array), and the weighted residual of the fitting procedure
@@ -58,11 +67,23 @@ def weighted_constrained_least_squares(
5867
# Create the standard weighted least squares objective function
5968
# (https://stats.stackexchange.com/a/333551)
6069
n_vars = A.shape[1]
61-
m = inv(sqrtm(Cov_b))
62-
mA = m @ A
63-
mb = m @ b
70+
M = np.linalg.pinv(sqrtm(Cov_b))
71+
MA = M @ A
72+
Mb = M @ b
6473
x = cp.Variable(n_vars)
65-
objective = cp.Minimize(cp.sum_squares(mA @ x - mb))
74+
objective = cp.Minimize(cp.sum_squares(MA @ x - Mb))
75+
76+
# Add a check for rank deficiency
77+
rank_MA = np.linalg.matrix_rank(MA)
78+
if not allow_rank_deficient and rank_MA < n_vars:
79+
raise Exception(
80+
f"The weighted design matrix is rank-deficient "
81+
f"(Cov_b^(-1/2).A={rank_MA} < n_vars={n_vars}). "
82+
"This probably means that you haven't supplied sufficient "
83+
"independent constraints to yield a unique solution to the "
84+
"problem. If you wish to proceed anyway, set "
85+
"allow_rank_deficient=True in the function call."
86+
)
6687

6788
constraints = []
6889
if equality_constraints is not None:
@@ -83,7 +104,7 @@ def weighted_constrained_least_squares(
83104

84105
# Set up the problem and solve it
85106
warns = []
86-
if len(constraints) > 1:
107+
if len(constraints) > 0:
87108
prob = cp.Problem(objective, constraints)
88109
else:
89110
prob = cp.Problem(objective)
@@ -104,7 +125,7 @@ def weighted_constrained_least_squares(
104125

105126
# Calculate the covariance matrix
106127
# (also from https://stats.stackexchange.com/a/333551)
107-
inv_Cov_b = np.linalg.inv(Cov_b)
108-
pcov = np.linalg.inv(A.T.dot(inv_Cov_b.dot(A)))
128+
inv_Cov_b = np.linalg.pinv(Cov_b)
129+
pcov = np.linalg.pinv(A.T.dot(inv_Cov_b.dot(A)))
109130

110131
return (popt, pcov, res)

burnman/tools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This file is part of BurnMan - a thermoelastic and thermodynamic toolkit for
22
# the Earth and Planetary Sciences
3-
# Copyright (C) 2012 - 2021 by the BurnMan team, released under the GNU
3+
# Copyright (C) 2012 - 2025 by the BurnMan team, released under the GNU
44
# GPL v2 or later.
55

66

@@ -23,4 +23,5 @@
2323
from . import plot
2424
from . import polytope
2525
from . import solution
26+
from . import thermobarometry
2627
from . import unitcell

burnman/tools/polytope.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,75 @@ def simplify_composite_with_composition(composite, composition):
312312
return Composite(new_phases)
313313
else:
314314
return composite
315+
316+
317+
def greedy_independent_endmember_selection(
318+
endmember_site_occupancies, site_occupancies, small_fraction_tol=0.0, norm_tol=1e-12
319+
):
320+
"""
321+
Greedy algorithm to select independent endmembers from a solution to approximate given
322+
site occupancies through a non-negative linear combination of endmember site occupancies.
323+
324+
This function starts with the full site occupancies and then iteratively selects endmembers
325+
to approximate those site occupancies. It loops through all possible endmembers in a number of steps.
326+
At each step the algorithm selects the endmember that can be subtracted in the largest amount from the
327+
current residual site occupancies without making any site occupancy negative.
328+
The process continues until either no endmember can be subtracted in an amount greater than fraction_tol,
329+
or the norm of the residual site occupancies is less than tol.
330+
331+
:param endmember_site_occupancies: A 2D array of shape (m, n), where m is the number of endmembers
332+
and n is the number of sites. Each row corresponds to the site occupancies of an endmember.
333+
:type endmember_site_occupancies: np.ndarray
334+
335+
:param site_occupancies: A 1D array of length n, representing the target site occupancies to approximate.
336+
:type site_occupancies: np.ndarray
337+
338+
:param small_fraction_tol: Algorithm stops if no endmember can be added with a molar fraction larger than this value.
339+
:type small_fraction_tol: float, optional, default 0.0
340+
341+
:param norm_tol: Algorithm stops if the norm of the residual site occupancies is less than this value.
342+
:type norm_tol: float, optional, default 1e-12
343+
344+
:return: indices of selected endmembers, their fractions, and the final residual site occupancies.
345+
:rtype: tuple(list[int], list[float], np.ndarray)
346+
"""
347+
348+
site_occupancy_residuals = site_occupancies.copy()
349+
indices = []
350+
fractions = []
351+
352+
for _ in range(endmember_site_occupancies.shape[0]):
353+
# compute fraction for each candidate endmember
354+
# fraction_i = min_j r_j / s_ij over s_ij > 0; if no s_ij>0 -> fraction_i = 0
355+
with np.errstate(divide="ignore", invalid="ignore"):
356+
ratios = np.where(
357+
endmember_site_occupancies > 0,
358+
site_occupancy_residuals[np.newaxis, :] / endmember_site_occupancies,
359+
np.inf,
360+
) # shape (m,n)
361+
362+
# For each row, take minimum ratio over columns where S>0; if all entries inf -> set 0
363+
fractions_all = np.min(ratios, axis=1)
364+
fractions_all[np.isinf(fractions_all)] = 0.0
365+
366+
# pick largest alpha
367+
i_max = int(np.argmax(fractions_all))
368+
fraction_max = float(fractions_all[i_max])
369+
if fraction_max <= small_fraction_tol:
370+
break # no further progress possible or desirable
371+
372+
# update residual
373+
site_occupancy_residuals = (
374+
site_occupancy_residuals - fraction_max * endmember_site_occupancies[i_max]
375+
)
376+
377+
# ensure non-negativity in elements of the residual
378+
site_occupancy_residuals[site_occupancy_residuals < 0] = 0.0
379+
indices.append(i_max)
380+
fractions.append(fraction_max)
381+
382+
# check if done
383+
if np.linalg.norm(site_occupancy_residuals) <= norm_tol:
384+
break
385+
386+
return indices, fractions, site_occupancy_residuals

0 commit comments

Comments
 (0)