pyIntersecT 0.1.4__py3-none-any.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.
- pyIntersecT/IntersecT.py +1023 -0
- pyIntersecT/Logo-IntersecT.png +0 -0
- pyIntersecT/__init__.py +0 -0
- pyIntersecT/mineral_aliases.py +447 -0
- pyIntersecT/mineral_symbols.toml +700 -0
- pyIntersecT/parsers.py +341 -0
- pyintersect-0.1.4.dist-info/METADATA +918 -0
- pyintersect-0.1.4.dist-info/RECORD +10 -0
- pyintersect-0.1.4.dist-info/WHEEL +4 -0
- pyintersect-0.1.4.dist-info/licenses/LICENSE +674 -0
pyIntersecT/IntersecT.py
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Sequence, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
import matplotlib.colors as colors
|
|
11
|
+
|
|
12
|
+
from .parsers import parse_table, extract_element_info, normalize_element_name
|
|
13
|
+
from .mineral_aliases import MineralAliases, discriminate_solvus
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QualityFactorAnalysis:
|
|
17
|
+
|
|
18
|
+
def __init__(self, mineral_symbols_toml: Optional[Path] = None):
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Core data structures
|
|
22
|
+
self.data = pd.DataFrame() # Legacy compatibility
|
|
23
|
+
self.labels = []
|
|
24
|
+
self.x = np.array([])
|
|
25
|
+
self.y = np.array([])
|
|
26
|
+
|
|
27
|
+
# Phase tracking
|
|
28
|
+
self.labels_new = []
|
|
29
|
+
self.phase_id = np.array([])
|
|
30
|
+
self.phase_name = []
|
|
31
|
+
|
|
32
|
+
# Observations
|
|
33
|
+
self.apfu_name = []
|
|
34
|
+
self.apfu_obs = np.array([])
|
|
35
|
+
self.obs_err = np.array([])
|
|
36
|
+
self.analysis_type = ''
|
|
37
|
+
|
|
38
|
+
# Output settings
|
|
39
|
+
self.color_scheme = 'viridis'
|
|
40
|
+
self.output_dir = ''
|
|
41
|
+
self.min_redchi2 = np.array([])
|
|
42
|
+
|
|
43
|
+
# Modern parsing support
|
|
44
|
+
self.model_blocks: list[dict] = []
|
|
45
|
+
self.coord_columns: Optional[list[str]] = None
|
|
46
|
+
self.coords_x: Optional[str] = None
|
|
47
|
+
self.coords_y: Optional[str] = None
|
|
48
|
+
self.phase_data: Optional[pd.DataFrame] = None
|
|
49
|
+
|
|
50
|
+
# Mineral aliases
|
|
51
|
+
if mineral_symbols_toml is None:
|
|
52
|
+
mineral_symbols_toml = Path(__file__).parent / "mineral_symbols.toml"
|
|
53
|
+
self.aliases = MineralAliases(mineral_symbols_toml)
|
|
54
|
+
|
|
55
|
+
# Quality factor constants
|
|
56
|
+
self.MIN_COEFF_ERR = 1
|
|
57
|
+
self.MAX_COEFF_ERR = 6
|
|
58
|
+
self.MIN_ERROR_THRESHOLD = 0.01
|
|
59
|
+
|
|
60
|
+
self._analysis_params = {
|
|
61
|
+
'EDS': (0.0703, 0.3574, 0.01, 0.1),
|
|
62
|
+
'WDS map': (0.0434, 0.3451, 0.005, 0.05),
|
|
63
|
+
'WDS spot':(0.023, 0.2772, 0.005, 0.05),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_default_symbols(cls):
|
|
68
|
+
"""Create instance with default mineral symbols file."""
|
|
69
|
+
return cls()
|
|
70
|
+
|
|
71
|
+
def _setup_logging(self):
|
|
72
|
+
if not self.output_dir:
|
|
73
|
+
self.output_dir = os.getcwd()
|
|
74
|
+
print(f"Warning: no output directory selected. Files will be saved in: {self.output_dir}")
|
|
75
|
+
log_path = os.path.join(self.output_dir, "log_IntersecT.txt")
|
|
76
|
+
logging.basicConfig(
|
|
77
|
+
filename=log_path,
|
|
78
|
+
filemode='w',
|
|
79
|
+
encoding='utf-8',
|
|
80
|
+
level=logging.INFO,
|
|
81
|
+
format='%(message)s',
|
|
82
|
+
force=True
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _log_print(self, message):
|
|
86
|
+
print(message)
|
|
87
|
+
logging.info(message)
|
|
88
|
+
|
|
89
|
+
def _fmt(self, value):
|
|
90
|
+
"""Format numeric values to at most 4 decimal places."""
|
|
91
|
+
if isinstance(value, float) or isinstance(value, np.floating):
|
|
92
|
+
formatted = f"{value:.4f}".rstrip('0')
|
|
93
|
+
if formatted.endswith('.'):
|
|
94
|
+
formatted += '0'
|
|
95
|
+
return formatted
|
|
96
|
+
if isinstance(value, np.ndarray):
|
|
97
|
+
def fmt_scalar(v):
|
|
98
|
+
s = f"{v:.4f}".rstrip('0')
|
|
99
|
+
return s + '0' if s.endswith('.') else s
|
|
100
|
+
return '[' + ', '.join(fmt_scalar(v) for v in value.flat) + ']'
|
|
101
|
+
return str(value)
|
|
102
|
+
|
|
103
|
+
# ================================
|
|
104
|
+
# I/O Methods
|
|
105
|
+
# ================================
|
|
106
|
+
|
|
107
|
+
def set_output_directory(self, path: Optional[str] = None):
|
|
108
|
+
"""Set the output directory.
|
|
109
|
+
|
|
110
|
+
When called without arguments, attempts to open a graphical directory
|
|
111
|
+
selection dialog via tkinter. If tkinter is not available, prompts the
|
|
112
|
+
user to enter a path manually. When called with a path argument, sets
|
|
113
|
+
the directory directly without any dialog.
|
|
114
|
+
"""
|
|
115
|
+
if path is None:
|
|
116
|
+
try:
|
|
117
|
+
import tkinter as tk
|
|
118
|
+
from tkinter import filedialog
|
|
119
|
+
root = tk.Tk()
|
|
120
|
+
root.withdraw()
|
|
121
|
+
path = filedialog.askdirectory(title="Select output directory")
|
|
122
|
+
root.destroy()
|
|
123
|
+
if not path:
|
|
124
|
+
path = os.getcwd()
|
|
125
|
+
print(f"No directory selected. Using current directory. Re-run this cell to change directory")
|
|
126
|
+
except Exception:
|
|
127
|
+
print("tkinter is not available on this system.")
|
|
128
|
+
print("On macOS with Homebrew Python, install it with: brew install python-tk@3.13")
|
|
129
|
+
path = input("Enter output directory path manually: ")
|
|
130
|
+
if not path.strip():
|
|
131
|
+
path = os.getcwd()
|
|
132
|
+
print(f"No directory entered. Using current directory: {path}")
|
|
133
|
+
self.output_dir = str(Path(path).expanduser().resolve())
|
|
134
|
+
self._log_print(f"The output directory is: {self.output_dir}")
|
|
135
|
+
self._setup_logging()
|
|
136
|
+
|
|
137
|
+
def load_model_output(self, coord_columns: Optional[Sequence[str]] = None,
|
|
138
|
+
filename: Optional[str] = None):
|
|
139
|
+
"""Load model output and apply phase name resolution with solvus discrimination.
|
|
140
|
+
|
|
141
|
+
When called without arguments, displays an interactive file chooser.
|
|
142
|
+
The file is loaded automatically when the user confirms the selection.
|
|
143
|
+
When called with a filename argument, loads the file directly.
|
|
144
|
+
"""
|
|
145
|
+
def _run(fname):
|
|
146
|
+
self._log_print(f"The data file is: {fname}")
|
|
147
|
+
self.model_blocks = parse_table(fname, coord_columns=coord_columns)
|
|
148
|
+
|
|
149
|
+
if not self.model_blocks:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
fc = list(self.model_blocks[0].get("coords", {}).keys())
|
|
153
|
+
self.coord_columns = fc if fc else (list(coord_columns) if coord_columns else None)
|
|
154
|
+
|
|
155
|
+
total_transformations = {}
|
|
156
|
+
raw_lookup = {}
|
|
157
|
+
unresolved_phases = set()
|
|
158
|
+
|
|
159
|
+
for block in self.model_blocks:
|
|
160
|
+
system = block.get("system", "perplex")
|
|
161
|
+
phases = []
|
|
162
|
+
|
|
163
|
+
for ph in block.get("phases", []):
|
|
164
|
+
original_phase_name = str(ph.get("phase", "")).strip()
|
|
165
|
+
if original_phase_name.lower() == "system":
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
phc = ph.copy()
|
|
169
|
+
phc["raw_phase"] = original_phase_name
|
|
170
|
+
phc["orig_phase"] = original_phase_name
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
phc["phase"] = self.aliases.resolve(original_phase_name, system)
|
|
174
|
+
except KeyError:
|
|
175
|
+
unresolved_phases.add(original_phase_name)
|
|
176
|
+
|
|
177
|
+
phases.append(phc)
|
|
178
|
+
raw_lookup.setdefault(phc["phase"], original_phase_name)
|
|
179
|
+
|
|
180
|
+
phases, block_transformations = discriminate_solvus(phases, system=system)
|
|
181
|
+
block["phases"] = phases
|
|
182
|
+
|
|
183
|
+
for key, count in block_transformations.items():
|
|
184
|
+
total_transformations[key] = total_transformations.get(key, 0) + count
|
|
185
|
+
|
|
186
|
+
self._log_print(f"\n{'-'*60}")
|
|
187
|
+
|
|
188
|
+
if unresolved_phases:
|
|
189
|
+
logging.warning(
|
|
190
|
+
f"The following phases were not found in the {system} aliases "
|
|
191
|
+
f"dictionary and were kept as-is: {', '.join(sorted(unresolved_phases))}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if system.lower() == "magemin":
|
|
195
|
+
self._log_print("Phase resolution summary (original → Warr 2021):")
|
|
196
|
+
seen = set()
|
|
197
|
+
for block in self.model_blocks:
|
|
198
|
+
for ph in block.get("phases", []):
|
|
199
|
+
raw = ph.get("raw_phase", ph.get("phase", ""))
|
|
200
|
+
final = ph.get("phase", "")
|
|
201
|
+
if (raw, final) not in seen:
|
|
202
|
+
self._log_print(f" {raw} → {final}")
|
|
203
|
+
seen.add((raw, final))
|
|
204
|
+
else:
|
|
205
|
+
self._log_print("Phase resolution and discrimination summary (original → Warr 2021 → endmember):")
|
|
206
|
+
|
|
207
|
+
parent_phases = {}
|
|
208
|
+
for (parent, assigned), count in total_transformations.items():
|
|
209
|
+
parent_phases.setdefault(parent, []).append((assigned, count))
|
|
210
|
+
|
|
211
|
+
for parent in sorted(parent_phases.keys()):
|
|
212
|
+
raw = raw_lookup.get(parent, parent)
|
|
213
|
+
assignments = sorted(parent_phases[parent], key=lambda x: x[1], reverse=True)
|
|
214
|
+
total_count = sum(count for _, count in assignments)
|
|
215
|
+
|
|
216
|
+
for assigned, count in assignments:
|
|
217
|
+
if parent == assigned:
|
|
218
|
+
self._log_print(f" {raw} → {parent} → {assigned} : {count}")
|
|
219
|
+
else:
|
|
220
|
+
percentage = (count / total_count) * 100
|
|
221
|
+
self._log_print(f" {raw} → {parent} → {assigned} : {count} ({percentage:.1f}%)")
|
|
222
|
+
|
|
223
|
+
self._log_print(f"{'-'*60}\n")
|
|
224
|
+
|
|
225
|
+
if filename is None:
|
|
226
|
+
try:
|
|
227
|
+
import tkinter as tk
|
|
228
|
+
from tkinter import filedialog
|
|
229
|
+
root = tk.Tk()
|
|
230
|
+
root.withdraw()
|
|
231
|
+
filename = filedialog.askopenfilename(
|
|
232
|
+
title="Select MAGEMin/Perple_X output file",
|
|
233
|
+
filetypes=[("Model output files", "*.phm *.csv *.txt"), ("All files", "*.*")]
|
|
234
|
+
)
|
|
235
|
+
root.destroy()
|
|
236
|
+
if not filename:
|
|
237
|
+
print("No file selected. Please re-run this cell and select a file, or pass the path directly: InT.load_model_output(filename=path)")
|
|
238
|
+
return
|
|
239
|
+
except Exception:
|
|
240
|
+
print("tkinter is not available on this system.")
|
|
241
|
+
print("On macOS with Homebrew Python, install it with: brew install python-tk@3.13")
|
|
242
|
+
filename = input("Enter path to MAGEMin/Perple_X output file manually: ")
|
|
243
|
+
if not filename.strip():
|
|
244
|
+
print("No file entered. Please re-run this cell.")
|
|
245
|
+
return
|
|
246
|
+
_run(filename)
|
|
247
|
+
|
|
248
|
+
def suggest_plot_coordinates(self):
|
|
249
|
+
"""Auto-suggest two coordinates for plotting."""
|
|
250
|
+
if not self.model_blocks or not self.coord_columns:
|
|
251
|
+
return None, None
|
|
252
|
+
|
|
253
|
+
# Priority: T then P, or X then P
|
|
254
|
+
temp_candidates = ["T(K)", "T", "T[°C]", "temperature"]
|
|
255
|
+
pres_candidates = ["P(bar)", "P[kbar]", "P", "pressure"]
|
|
256
|
+
x_candidates = ["X", "x", "X[0.0-1.0]", "X(C1)", "x(c1)", "Y(CO2)", "y(co2)"]
|
|
257
|
+
|
|
258
|
+
def pick(cands, cols):
|
|
259
|
+
for cand in cands:
|
|
260
|
+
for c in cols:
|
|
261
|
+
if c.lower() == cand.lower():
|
|
262
|
+
return c
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def n_unique(col_name):
|
|
266
|
+
"""Count unique values for a coordinate across all blocks."""
|
|
267
|
+
vals = set()
|
|
268
|
+
for block in self.model_blocks:
|
|
269
|
+
v = block.get("coords", {}).get(col_name)
|
|
270
|
+
if v is not None:
|
|
271
|
+
vals.add(v)
|
|
272
|
+
return len(vals)
|
|
273
|
+
|
|
274
|
+
def is_independent_variable(var_name, other_vars):
|
|
275
|
+
"""
|
|
276
|
+
Check if var_name is an independent variable by examining data structure.
|
|
277
|
+
Returns True if for each unique value of var_name, there are multiple
|
|
278
|
+
combinations of other variables (indicating var_name is outer loop).
|
|
279
|
+
"""
|
|
280
|
+
# Group data by var_name and collect unique combinations of other vars
|
|
281
|
+
var_groups = {}
|
|
282
|
+
for block in self.model_blocks:
|
|
283
|
+
coords = block.get("coords", {})
|
|
284
|
+
if var_name not in coords:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
var_val = coords[var_name]
|
|
288
|
+
other_vals = tuple(coords.get(ov) for ov in other_vars if ov in coords)
|
|
289
|
+
|
|
290
|
+
if var_val not in var_groups:
|
|
291
|
+
var_groups[var_val] = set()
|
|
292
|
+
var_groups[var_val].add(other_vals)
|
|
293
|
+
|
|
294
|
+
# If most var_name values have multiple other_var combinations,
|
|
295
|
+
# var_name is likely an independent variable
|
|
296
|
+
if not var_groups:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
avg_combinations = sum(len(combos) for combos in var_groups.values()) / len(var_groups)
|
|
300
|
+
return avg_combinations > 1.5
|
|
301
|
+
|
|
302
|
+
t = pick(temp_candidates, self.coord_columns)
|
|
303
|
+
p = pick(pres_candidates, self.coord_columns)
|
|
304
|
+
x = pick(x_candidates, self.coord_columns)
|
|
305
|
+
|
|
306
|
+
# Check variance for each coordinate
|
|
307
|
+
nt = n_unique(t) if t else 0
|
|
308
|
+
np_val = n_unique(p) if p else 0
|
|
309
|
+
nx = n_unique(x) if x else 0
|
|
310
|
+
|
|
311
|
+
# Filter out non-varying coordinates
|
|
312
|
+
varying = {}
|
|
313
|
+
if nt > 1:
|
|
314
|
+
varying['t'] = t
|
|
315
|
+
if np_val > 1:
|
|
316
|
+
varying['p'] = p
|
|
317
|
+
if nx > 1:
|
|
318
|
+
varying['x'] = x
|
|
319
|
+
|
|
320
|
+
# If all three coordinates vary, use structural analysis
|
|
321
|
+
if 't' in varying and 'p' in varying and 'x' in varying:
|
|
322
|
+
# Check if X is structured as independent variable with PT as path
|
|
323
|
+
x_is_independent = is_independent_variable(x, [p, t])
|
|
324
|
+
|
|
325
|
+
if x_is_independent:
|
|
326
|
+
print(f"Auto-detected X-path configuration: X is independent variable with varying P-T conditions")
|
|
327
|
+
return x, p # X on x-axis, P on y-axis
|
|
328
|
+
else:
|
|
329
|
+
print(f"All coordinates vary but X is not independent variable. Using standard T-P configuration.")
|
|
330
|
+
return t, p
|
|
331
|
+
|
|
332
|
+
# Standard priority logic for cases with 2 or fewer varying coordinates
|
|
333
|
+
if 't' in varying and 'p' in varying:
|
|
334
|
+
return t, p
|
|
335
|
+
if 'x' in varying and 'p' in varying:
|
|
336
|
+
return x, p
|
|
337
|
+
if 'x' in varying and 't' in varying:
|
|
338
|
+
return x, t
|
|
339
|
+
|
|
340
|
+
# Fallback: first two varying coordinates or first two columns
|
|
341
|
+
varying_list = [varying[k] for k in ['t', 'p', 'x'] if k in varying]
|
|
342
|
+
if len(varying_list) >= 2:
|
|
343
|
+
return varying_list[0], varying_list[1]
|
|
344
|
+
|
|
345
|
+
if len(self.coord_columns) >= 2:
|
|
346
|
+
return self.coord_columns[0], self.coord_columns[1]
|
|
347
|
+
|
|
348
|
+
return None, None
|
|
349
|
+
|
|
350
|
+
def _convert_coordinate_scalar(self, value: float, name: str) -> float:
|
|
351
|
+
"""
|
|
352
|
+
Convert a single coordinate value to standard units.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
value: Single coordinate value
|
|
356
|
+
name: Coordinate column name
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Converted value
|
|
360
|
+
"""
|
|
361
|
+
# Use array version and extract scalar
|
|
362
|
+
converted_array, _ = self._convert_coordinate(np.array([value]), name)
|
|
363
|
+
return converted_array[0]
|
|
364
|
+
|
|
365
|
+
def _convert_coordinate(self, values: np.ndarray, name: str) -> tuple[np.ndarray, str]:
|
|
366
|
+
"""
|
|
367
|
+
Convert coordinate values to standard units.
|
|
368
|
+
|
|
369
|
+
Converts temperature from Kelvin to Celsius and pressure from bar/kbar to GPa.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
values: Coordinate values array
|
|
373
|
+
name: Coordinate column name
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Tuple of (converted_values, converted_name)
|
|
377
|
+
"""
|
|
378
|
+
name_lower = name.lower()
|
|
379
|
+
|
|
380
|
+
# Temperature: T(K) or T[K] variants, convert to Celsius
|
|
381
|
+
if "t(k)" in name_lower or "t[k]" in name_lower or (("temp" in name_lower or name_lower == "t") and "k" in name_lower):
|
|
382
|
+
return values - 273, "T(°C)"
|
|
383
|
+
|
|
384
|
+
# Temperature: Already in Celsius but with bracket notation
|
|
385
|
+
if "t[°c]" in name_lower or "t[c]" in name_lower:
|
|
386
|
+
return values, "T(°C)"
|
|
387
|
+
|
|
388
|
+
# Pressure in bar: P(bar) or variants, excluding kbar
|
|
389
|
+
if "p(bar)" in name_lower or (("p" in name_lower or "pressure" in name_lower) and "bar" in name_lower and "kbar" not in name_lower):
|
|
390
|
+
return values / 10000, "P(GPa)"
|
|
391
|
+
|
|
392
|
+
# Pressure in kbar: P[kbar] or variants containing kbar
|
|
393
|
+
if "p[kbar]" in name_lower or "kbar" in name_lower:
|
|
394
|
+
return values / 10, "P(GPa)"
|
|
395
|
+
|
|
396
|
+
# No conversion needed
|
|
397
|
+
return values, name
|
|
398
|
+
|
|
399
|
+
def set_plot_coordinates(self, xname: str, yname: str):
|
|
400
|
+
"""Set coordinate columns for plotting and build x, y arrays."""
|
|
401
|
+
self.coords_x = xname
|
|
402
|
+
self.coords_y = yname
|
|
403
|
+
|
|
404
|
+
# Extract x, y values from blocks
|
|
405
|
+
x_vals = []
|
|
406
|
+
y_vals = []
|
|
407
|
+
|
|
408
|
+
if self.model_blocks:
|
|
409
|
+
available_coords = list(self.model_blocks[0].get("coords", {}).keys())
|
|
410
|
+
|
|
411
|
+
x_found = xname in available_coords
|
|
412
|
+
y_found = yname in available_coords
|
|
413
|
+
|
|
414
|
+
if not x_found or not y_found:
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"Requested coordinates not found in data. "
|
|
417
|
+
f"Requested: x='{xname}', y='{yname}'. "
|
|
418
|
+
f"Available: {available_coords}"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
for block in self.model_blocks:
|
|
422
|
+
coords = block.get("coords", {})
|
|
423
|
+
if xname in coords and yname in coords:
|
|
424
|
+
x_vals.append(coords[xname])
|
|
425
|
+
y_vals.append(coords[yname])
|
|
426
|
+
|
|
427
|
+
self.x = np.array(x_vals)
|
|
428
|
+
self.y = np.array(y_vals)
|
|
429
|
+
|
|
430
|
+
# Initialize labels with original coordinate names
|
|
431
|
+
self.labels = [xname, yname]
|
|
432
|
+
|
|
433
|
+
# Apply conversions using the class method
|
|
434
|
+
self.x, self.labels[0] = self._convert_coordinate(self.x, xname)
|
|
435
|
+
self.y, self.labels[1] = self._convert_coordinate(self.y, yname)
|
|
436
|
+
|
|
437
|
+
self._log_print(f"The coordinate variables are: {self.labels}")
|
|
438
|
+
self._log_print(f"The {self.labels[0]} range is: {self._fmt(self.x.min())} to {self._fmt(self.x.max())}")
|
|
439
|
+
self._log_print(f"The {self.labels[1]} range is: {self._fmt(self.y.min())} to {self._fmt(self.y.max())}")
|
|
440
|
+
|
|
441
|
+
def import_analytical_compo(self, filename: Optional[str] = None):
|
|
442
|
+
"""Import composition data from a file.
|
|
443
|
+
|
|
444
|
+
When called without arguments, displays an interactive file chooser.
|
|
445
|
+
The file is loaded automatically when the user confirms the selection.
|
|
446
|
+
When called with a filename argument, loads the file directly.
|
|
447
|
+
"""
|
|
448
|
+
def _run(fname):
|
|
449
|
+
self._log_print("The composition file is: " + fname)
|
|
450
|
+
|
|
451
|
+
input_data = pd.read_csv(fname, sep='\t', header=None, comment='#')
|
|
452
|
+
arrays = input_data.values
|
|
453
|
+
|
|
454
|
+
self.apfu_name = [str(i) for i in arrays[0] if str(i) != 'nan']
|
|
455
|
+
self.apfu_obs = np.array([float(i) for i in arrays[1] if str(i) != 'nan'])
|
|
456
|
+
|
|
457
|
+
obs_err_raw = np.array([float(i) if str(i) != '-' else '-'
|
|
458
|
+
for i in arrays[2] if str(i) != 'nan'])
|
|
459
|
+
|
|
460
|
+
if np.all(obs_err_raw == '-'):
|
|
461
|
+
self.obs_err = np.array([])
|
|
462
|
+
else:
|
|
463
|
+
self.obs_err = np.where((obs_err_raw != '-') & (obs_err_raw.astype(float) < 0.01),
|
|
464
|
+
0.01, obs_err_raw).astype(float)
|
|
465
|
+
|
|
466
|
+
self.analysis_type = ''.join([str(i) for i in arrays[3] if str(i) != 'nan'])
|
|
467
|
+
|
|
468
|
+
phase_name_ordered = []
|
|
469
|
+
for apfu_full_name in self.apfu_name:
|
|
470
|
+
if "_" in apfu_full_name:
|
|
471
|
+
phase = apfu_full_name.split("_", 1)[0]
|
|
472
|
+
if phase not in phase_name_ordered:
|
|
473
|
+
phase_name_ordered.append(phase)
|
|
474
|
+
self.phase_name = phase_name_ordered
|
|
475
|
+
self.color_scheme = ''.join([str(i) for i in arrays[5] if str(i) != 'nan'])
|
|
476
|
+
|
|
477
|
+
self._log_print(f"The input variables are: {self.apfu_name}")
|
|
478
|
+
self._log_print(f"The input compositions are: {self.apfu_obs}")
|
|
479
|
+
self._log_print(f"The input uncertainties are: {self.obs_err}")
|
|
480
|
+
self._log_print(f"The selected analysis type is: {self.analysis_type}")
|
|
481
|
+
self._log_print(f"The phase names are: {self.phase_name}")
|
|
482
|
+
self._log_print(f"The color scheme is: {self.color_scheme}")
|
|
483
|
+
|
|
484
|
+
if len(self.obs_err) == 0:
|
|
485
|
+
self.obs_err = self.calc_obs_err(self.apfu_obs)
|
|
486
|
+
|
|
487
|
+
if filename is None:
|
|
488
|
+
try:
|
|
489
|
+
import tkinter as tk
|
|
490
|
+
from tkinter import filedialog
|
|
491
|
+
root = tk.Tk()
|
|
492
|
+
root.withdraw()
|
|
493
|
+
filename = filedialog.askopenfilename(
|
|
494
|
+
title="Select measured compositions file",
|
|
495
|
+
filetypes=[("Text files", "*.txt *.tsv"), ("All files", "*.*")]
|
|
496
|
+
)
|
|
497
|
+
root.destroy()
|
|
498
|
+
if not filename:
|
|
499
|
+
print("No file selected. Please re-run this cell and select a file, or pass the path directly: InT.import_analytical_compo(filename=path)")
|
|
500
|
+
return
|
|
501
|
+
except Exception:
|
|
502
|
+
print("tkinter is not available on this system.")
|
|
503
|
+
print("On macOS with Homebrew Python, install it with: brew install python-tk@3.13")
|
|
504
|
+
filename = input("Enter path to measured compositions file manually: ")
|
|
505
|
+
if not filename.strip():
|
|
506
|
+
print("No file entered. Please re-run this cell.")
|
|
507
|
+
return
|
|
508
|
+
_run(filename)
|
|
509
|
+
|
|
510
|
+
# ======================
|
|
511
|
+
# Build intersect table
|
|
512
|
+
# ======================
|
|
513
|
+
|
|
514
|
+
def build_intersect_table(self):
|
|
515
|
+
"""
|
|
516
|
+
Build phase_data DataFrame and legacy self.data structure.
|
|
517
|
+
"""
|
|
518
|
+
if not self.model_blocks:
|
|
519
|
+
raise RuntimeError("No model blocks loaded")
|
|
520
|
+
if not (self.coords_x and self.coords_y):
|
|
521
|
+
raise RuntimeError("Plot coordinates not set")
|
|
522
|
+
if len(self.apfu_name) == 0:
|
|
523
|
+
raise RuntimeError("No observed elements")
|
|
524
|
+
|
|
525
|
+
# Parse observed phase-element combinations
|
|
526
|
+
observed_phases_elements = {}
|
|
527
|
+
for apfu_full_name in self.apfu_name:
|
|
528
|
+
if "_" not in apfu_full_name:
|
|
529
|
+
continue
|
|
530
|
+
parts = apfu_full_name.split("_", 1)
|
|
531
|
+
phase_warr = parts[0]
|
|
532
|
+
element = parts[1]
|
|
533
|
+
|
|
534
|
+
if phase_warr not in observed_phases_elements:
|
|
535
|
+
observed_phases_elements[phase_warr] = []
|
|
536
|
+
observed_phases_elements[phase_warr].append(element)
|
|
537
|
+
|
|
538
|
+
records = []
|
|
539
|
+
|
|
540
|
+
for block in self.model_blocks:
|
|
541
|
+
coords = block.get("coords", {})
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
x_val = coords[self.coords_x]
|
|
545
|
+
y_val = coords[self.coords_y]
|
|
546
|
+
except KeyError:
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
# Apply unit conversions using helper method
|
|
550
|
+
x_val = self._convert_coordinate_scalar(x_val, self.coords_x)
|
|
551
|
+
y_val = self._convert_coordinate_scalar(y_val, self.coords_y)
|
|
552
|
+
|
|
553
|
+
# Use already processed phases from block
|
|
554
|
+
phases = block.get("phases", [])
|
|
555
|
+
|
|
556
|
+
# Build row
|
|
557
|
+
row = {"x": float(x_val), "y": float(y_val)}
|
|
558
|
+
|
|
559
|
+
for phase_warr, elements in observed_phases_elements.items():
|
|
560
|
+
matching_phases = [p for p in phases
|
|
561
|
+
if p.get("phase", "").lower() == phase_warr.lower()]
|
|
562
|
+
|
|
563
|
+
if not matching_phases:
|
|
564
|
+
for element in elements:
|
|
565
|
+
column_name = f"{phase_warr}_{element}"
|
|
566
|
+
row[column_name] = np.nan
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
model_phase = matching_phases[0]
|
|
570
|
+
|
|
571
|
+
for element in elements:
|
|
572
|
+
value = self._find_element_in_phase(model_phase, element)
|
|
573
|
+
column_name = f"{phase_warr}_{element}"
|
|
574
|
+
row[column_name] = float(value) if value is not None else np.nan
|
|
575
|
+
|
|
576
|
+
records.append(row)
|
|
577
|
+
|
|
578
|
+
if not records:
|
|
579
|
+
raise RuntimeError("No valid records assembled")
|
|
580
|
+
|
|
581
|
+
self.phase_data = pd.DataFrame.from_records(records)
|
|
582
|
+
self.phase_data.sort_values(["y", "x"], inplace=True, ignore_index=True)
|
|
583
|
+
self.x = self.phase_data["x"].values
|
|
584
|
+
self.y = self.phase_data["y"].values
|
|
585
|
+
|
|
586
|
+
# Build legacy self.data structure for compatibility with original methods
|
|
587
|
+
self._build_legacy_data_structure()
|
|
588
|
+
|
|
589
|
+
def _find_element_in_phase(self, phase_dict: dict, target_element: str):
|
|
590
|
+
"""Find element value in phase dictionary."""
|
|
591
|
+
target_norm = normalize_element_name(target_element)
|
|
592
|
+
|
|
593
|
+
# Try simple element key first (created by parser for discrimination)
|
|
594
|
+
if target_norm in phase_dict:
|
|
595
|
+
return phase_dict[target_norm]
|
|
596
|
+
|
|
597
|
+
# Try _apfu key (normalized by parser)
|
|
598
|
+
key_apfu = f"{target_norm}_apfu"
|
|
599
|
+
if key_apfu in phase_dict:
|
|
600
|
+
return phase_dict[key_apfu]
|
|
601
|
+
|
|
602
|
+
# Fallback: search through all keys and extract with coefficient
|
|
603
|
+
for key, value in phase_dict.items():
|
|
604
|
+
if not isinstance(key, str) or key in ("phase", "orig_phase"):
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
elem, coef = extract_element_info(key)
|
|
608
|
+
elem_norm = normalize_element_name(elem)
|
|
609
|
+
|
|
610
|
+
if elem_norm == target_norm:
|
|
611
|
+
try:
|
|
612
|
+
num_val = float(value)
|
|
613
|
+
# Only apply coefficient if this is a raw oxide column
|
|
614
|
+
# (not already an _apfu column)
|
|
615
|
+
if not key.endswith("_apfu"):
|
|
616
|
+
return num_val * coef
|
|
617
|
+
else:
|
|
618
|
+
return num_val
|
|
619
|
+
except (ValueError, TypeError):
|
|
620
|
+
continue
|
|
621
|
+
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
def _build_legacy_data_structure(self):
|
|
625
|
+
"""Build legacy self.data structure for compatibility with calculation methods."""
|
|
626
|
+
# Create column labels matching original format
|
|
627
|
+
data_cols = [c for c in self.phase_data.columns if c not in ("x", "y")]
|
|
628
|
+
self.labels = np.array([self.labels[0], self.labels[1]] + data_cols)
|
|
629
|
+
|
|
630
|
+
# Build data DataFrame
|
|
631
|
+
data_dict = {
|
|
632
|
+
self.labels[0]: self.x,
|
|
633
|
+
self.labels[1]: self.y
|
|
634
|
+
}
|
|
635
|
+
for col in data_cols:
|
|
636
|
+
data_dict[col] = self.phase_data[col].values
|
|
637
|
+
|
|
638
|
+
self.data = pd.DataFrame(data_dict)
|
|
639
|
+
|
|
640
|
+
# Build phase_id array (group by phase name)
|
|
641
|
+
phase_ids = {}
|
|
642
|
+
current_id = 1
|
|
643
|
+
self.phase_id = []
|
|
644
|
+
|
|
645
|
+
for col in data_cols:
|
|
646
|
+
if "_" in col:
|
|
647
|
+
phase = col.split("_")[0]
|
|
648
|
+
if phase not in phase_ids:
|
|
649
|
+
phase_ids[phase] = current_id
|
|
650
|
+
current_id += 1
|
|
651
|
+
self.phase_id.append(phase_ids[phase])
|
|
652
|
+
else:
|
|
653
|
+
self.phase_id.append(0)
|
|
654
|
+
|
|
655
|
+
self.phase_id = np.array(self.phase_id)
|
|
656
|
+
|
|
657
|
+
# ====================
|
|
658
|
+
# Error calculation
|
|
659
|
+
# ====================
|
|
660
|
+
|
|
661
|
+
def calc_obs_err(self, apfu_obs):
|
|
662
|
+
"""
|
|
663
|
+
Calculate observation errors based on analysis type.
|
|
664
|
+
Uses empirical power-law relationships determined for different analytical techniques:
|
|
665
|
+
- EDS: calc_err = 0.0703 * apfu^0.3574
|
|
666
|
+
- WDS map: calc_err = 0.0434 * apfu^0.3451
|
|
667
|
+
- WDS spot: calc_err = 0.023 * apfu^0.2772
|
|
668
|
+
Args:
|
|
669
|
+
apfu_obs: Observed APFU values
|
|
670
|
+
Returns:
|
|
671
|
+
Calculated uncertainties clipped to technique-specific limits
|
|
672
|
+
"""
|
|
673
|
+
params = self._analysis_params.get(self.analysis_type)
|
|
674
|
+
if params is None:
|
|
675
|
+
self._log_print('Please enter a valid analysis type (EDS, WDS map, WDS spot)')
|
|
676
|
+
sys.exit()
|
|
677
|
+
coeff, exp, min_err, max_err = params
|
|
678
|
+
calc_err = np.clip(coeff * apfu_obs**exp, min_err, max_err)
|
|
679
|
+
self._log_print(f'The calculated uncertainties are: {self._fmt(calc_err)}')
|
|
680
|
+
return calc_err
|
|
681
|
+
|
|
682
|
+
# ================================
|
|
683
|
+
# Quality factor calculations
|
|
684
|
+
# ================================
|
|
685
|
+
|
|
686
|
+
def Q_elem(self, apfu_obs, model, obs_err):
|
|
687
|
+
"""
|
|
688
|
+
Calculate quality factor for each element.
|
|
689
|
+
|
|
690
|
+
Quantifies the agreement between observed and modeled element compositions.
|
|
691
|
+
"""
|
|
692
|
+
obs_err = np.where(obs_err < self.MIN_ERROR_THRESHOLD, self.MIN_ERROR_THRESHOLD, obs_err)
|
|
693
|
+
diff = np.abs(apfu_obs - model)
|
|
694
|
+
num = np.clip(diff - obs_err / self.MIN_COEFF_ERR, 0, self.MAX_COEFF_ERR * obs_err)
|
|
695
|
+
Qcmp_elem = 100 * np.abs(1 - num / (self.MAX_COEFF_ERR * obs_err))**(model + 1)
|
|
696
|
+
return Qcmp_elem
|
|
697
|
+
|
|
698
|
+
def Q_phase(self, apfu_obs, model, obs_err):
|
|
699
|
+
"""
|
|
700
|
+
Calculate quality factor for each phase.
|
|
701
|
+
|
|
702
|
+
Averages element quality factors to obtain phase-level quality assessment.
|
|
703
|
+
"""
|
|
704
|
+
obs_err = np.where(obs_err < self.MIN_ERROR_THRESHOLD, self.MIN_ERROR_THRESHOLD, obs_err)
|
|
705
|
+
diff = np.abs(apfu_obs - model)
|
|
706
|
+
num = np.clip(diff - obs_err / self.MIN_COEFF_ERR, 0, self.MAX_COEFF_ERR * obs_err)
|
|
707
|
+
Qcmp_elem = np.abs(1 - num / (self.MAX_COEFF_ERR * obs_err))**(model + 1)
|
|
708
|
+
Qcmp_phase = np.sum(Qcmp_elem) / len(Qcmp_elem) * 100
|
|
709
|
+
return Qcmp_phase
|
|
710
|
+
|
|
711
|
+
def chi2(self, apfu_obs, model, obs_err) -> float:
|
|
712
|
+
"""
|
|
713
|
+
Calculate chi-squared statistics.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
apfu_obs: Observed APFU values
|
|
717
|
+
model: Modeled values
|
|
718
|
+
obs_err: Observation uncertainties
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
χ² statistic
|
|
722
|
+
"""
|
|
723
|
+
return np.sum((apfu_obs - model)**2 / obs_err**2)
|
|
724
|
+
|
|
725
|
+
def red_chi2(self, apfu_obs, model, obs_err, f) -> float:
|
|
726
|
+
"""
|
|
727
|
+
Calculate reduced chi-squared statistics.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
apfu_obs: Observed APFU values
|
|
731
|
+
model: Modeled values
|
|
732
|
+
obs_err: Observation uncertainties
|
|
733
|
+
f: Number of degrees of freedom
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
Reduced χ² statistic
|
|
737
|
+
"""
|
|
738
|
+
return self.chi2(apfu_obs, model, obs_err) / (f - 1)
|
|
739
|
+
|
|
740
|
+
def norm_weight(self, weight: np.ndarray) -> np.ndarray:
|
|
741
|
+
"""
|
|
742
|
+
Normalize weight array to sum to 1.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
weight: Weight array
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
Normalized weights
|
|
749
|
+
"""
|
|
750
|
+
weight_norm = weight / np.sum(weight)
|
|
751
|
+
return weight_norm
|
|
752
|
+
|
|
753
|
+
def Q_tot(self, Qcmp_tot, weight_norm) -> float:
|
|
754
|
+
"""
|
|
755
|
+
Calculate total quality factor from weighted phase contributions.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
Qcmp_tot: Quality factor values for all phases
|
|
759
|
+
weight_norm: Normalized weights for each phase
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
Weighted total quality factor
|
|
763
|
+
"""
|
|
764
|
+
return np.sum(Qcmp_tot * weight_norm)
|
|
765
|
+
|
|
766
|
+
# =====================
|
|
767
|
+
# Plotting methods
|
|
768
|
+
# =====================
|
|
769
|
+
|
|
770
|
+
def _plot_contour_map(self, data_2d: np.ndarray, title: str, label_log_scale: bool = False,
|
|
771
|
+
clim_max: float = 100):
|
|
772
|
+
"""
|
|
773
|
+
Helper method to plot a contour map with common formatting.
|
|
774
|
+
|
|
775
|
+
Handles imshow, colorbar, contour, and clabel operations.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
data_2d: 2D data array
|
|
779
|
+
title: Plot title
|
|
780
|
+
label_log_scale: Whether to use logarithmic scale
|
|
781
|
+
clim_max: Maximum value for color limit
|
|
782
|
+
"""
|
|
783
|
+
plt.imshow(data_2d, cmap=self.color_scheme, aspect='auto', origin='lower',
|
|
784
|
+
extent=[min(self.x), max(self.x), min(self.y), max(self.y)],
|
|
785
|
+
norm=colors.LogNorm() if label_log_scale else None)
|
|
786
|
+
plt.colorbar()
|
|
787
|
+
plt.title(title)
|
|
788
|
+
plt.xlabel(self.labels[0])
|
|
789
|
+
plt.ylabel(self.labels[1])
|
|
790
|
+
|
|
791
|
+
if not label_log_scale:
|
|
792
|
+
plt.clim(0, clim_max)
|
|
793
|
+
|
|
794
|
+
# Calculate contour levels
|
|
795
|
+
min_val = np.nanmin(data_2d)
|
|
796
|
+
max_val = np.nanmax(data_2d)
|
|
797
|
+
|
|
798
|
+
# Only plot contours if min and max are different
|
|
799
|
+
if min_val != max_val:
|
|
800
|
+
step = (max_val - min_val) / 10
|
|
801
|
+
|
|
802
|
+
# Determine appropriate contour levels
|
|
803
|
+
if label_log_scale:
|
|
804
|
+
if min_val <= 1:
|
|
805
|
+
levels = np.arange(1, max_val, step)
|
|
806
|
+
else:
|
|
807
|
+
levels = np.arange(np.round(min_val, decimals=1)+0.1, max_val, step)
|
|
808
|
+
else:
|
|
809
|
+
levels = np.arange(0, clim_max + 10, 10)
|
|
810
|
+
|
|
811
|
+
contoured = plt.contour(data_2d, levels=levels, colors="white",
|
|
812
|
+
linewidths=0.5, origin="lower",
|
|
813
|
+
extent=[min(self.x), max(self.x), min(self.y), max(self.y)],
|
|
814
|
+
norm=colors.LogNorm() if label_log_scale else None)
|
|
815
|
+
plt.clabel(contoured, inline=True, fontsize=10, fmt='%1.0f' if not label_log_scale else '%1.1f')
|
|
816
|
+
|
|
817
|
+
return min_val, max_val
|
|
818
|
+
|
|
819
|
+
def plot_elem(self, Qcmp, i):
|
|
820
|
+
"""Plot quality factor for each element."""
|
|
821
|
+
Qcmp_2D = np.reshape(Qcmp, (len(np.unique(self.y)), len(np.unique(self.x))))
|
|
822
|
+
max_val = self._plot_contour_map(Qcmp_2D, f'Quality factor for {self.apfu_name[i]}',
|
|
823
|
+
label_log_scale=False, clim_max=100)
|
|
824
|
+
self._log_print(f'The maximum value of the quality factor for {self.apfu_name[i]} is: {self._fmt(np.nanmax(Qcmp_2D))}')
|
|
825
|
+
|
|
826
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
827
|
+
plt.savefig(os.path.join(self.output_dir, f"Qcmp_{self.apfu_name[i]}.pdf"), format='pdf')
|
|
828
|
+
plt.show()
|
|
829
|
+
plt.close()
|
|
830
|
+
|
|
831
|
+
def plot_phase(self, Qcmp, i):
|
|
832
|
+
"""Plot quality factor for each phase."""
|
|
833
|
+
Qcmp_2D = np.reshape(Qcmp, (len(np.unique(self.y)), len(np.unique(self.x))))
|
|
834
|
+
self._plot_contour_map(Qcmp_2D, f'Quality factor for {self.phase_name[i-1]}',
|
|
835
|
+
label_log_scale=False, clim_max=100)
|
|
836
|
+
self._log_print(f'The maximum value of the quality factor for {self.phase_name[i-1]} is: {self._fmt(np.nanmax(Qcmp_2D))}')
|
|
837
|
+
|
|
838
|
+
n_p = np.count_nonzero(self.y == self.y[0])
|
|
839
|
+
max_Qcmp = np.where(Qcmp_2D == np.nanmax(Qcmp_2D))
|
|
840
|
+
max_Qcmp_y = np.mean(self.y[max_Qcmp[0] * n_p])
|
|
841
|
+
max_Qcmp_x = np.mean(self.x[max_Qcmp[1]])
|
|
842
|
+
self._log_print(f'The {self.labels[0]} and {self.labels[1]} position of the maximum Qcmp of {self.phase_name[i-1]} is: {self._fmt(max_Qcmp_x)}, {self._fmt(max_Qcmp_y)}')
|
|
843
|
+
|
|
844
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
845
|
+
plt.savefig(os.path.join(self.output_dir, f"Qcmp_{self.phase_name[i-1]}.pdf"), format='pdf')
|
|
846
|
+
plt.show()
|
|
847
|
+
plt.close()
|
|
848
|
+
|
|
849
|
+
def plot_tot(self, Qcmp, title):
|
|
850
|
+
"""Plot total quality factor."""
|
|
851
|
+
Qcmp_2D = np.reshape(Qcmp, (len(np.unique(self.y)), len(np.unique(self.x))))
|
|
852
|
+
max_val = self._plot_contour_map(Qcmp_2D, f'{title} Q*cmp', label_log_scale=False, clim_max=100)
|
|
853
|
+
|
|
854
|
+
self._log_print(f'The maximum value of the Q*cmp is: {self._fmt(np.nanmax(Qcmp_2D))}')
|
|
855
|
+
|
|
856
|
+
max_Qcmp = np.where(Qcmp_2D == np.nanmax(Qcmp_2D))
|
|
857
|
+
n_p = np.count_nonzero(self.y == self.y[0])
|
|
858
|
+
max_Qcmp_y = self.y[max_Qcmp[0]*n_p]
|
|
859
|
+
max_Qcmp_x = self.x[max_Qcmp[1]]
|
|
860
|
+
max_Qcmp_x = np.mean(max_Qcmp_x)
|
|
861
|
+
max_Qcmp_y = np.mean(max_Qcmp_y)
|
|
862
|
+
|
|
863
|
+
self._log_print(f'The {self.labels[0]} and {self.labels[1]} position of the maximum Q*cmp is: {self._fmt(max_Qcmp_x)} , {self._fmt(max_Qcmp_y)}')
|
|
864
|
+
|
|
865
|
+
plt.plot(max_Qcmp_x, max_Qcmp_y, "ro", markersize=2)
|
|
866
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
867
|
+
plt.savefig(os.path.join(self.output_dir, f"{title}_Qcmp_tot.pdf"), format='pdf')
|
|
868
|
+
plt.show()
|
|
869
|
+
plt.close()
|
|
870
|
+
|
|
871
|
+
def plot_redchi2_phase(self, redchi2, i, f):
|
|
872
|
+
"""Plot reduced chi-squared for phase."""
|
|
873
|
+
redchi2_2D = np.reshape(redchi2, (len(np.unique(self.y)), len(np.unique(self.x))))
|
|
874
|
+
|
|
875
|
+
prefix = "Reduced χ2" if f > 2 else "χ2"
|
|
876
|
+
self._log_print(f"The number of elements in {self.phase_name[i-1]} is: {f}")
|
|
877
|
+
min_val, _ = self._plot_contour_map(redchi2_2D, f'{prefix} {self.phase_name[i-1]}',
|
|
878
|
+
label_log_scale=True, clim_max=None)
|
|
879
|
+
self._log_print(f'The minimum {prefix} value for {self.phase_name[i-1]} is: {self._fmt(min_val)}')
|
|
880
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
881
|
+
plt.savefig(os.path.join(self.output_dir, f"{prefix.replace(' ', '')}_{self.phase_name[i-1]}.pdf"), format='pdf')
|
|
882
|
+
plt.show()
|
|
883
|
+
plt.close()
|
|
884
|
+
return min_val
|
|
885
|
+
|
|
886
|
+
def plot_redchi2_tot(self, redchi2):
|
|
887
|
+
"""Plot total reduced chi-squared."""
|
|
888
|
+
redchi2_2D = np.reshape(redchi2, (len(np.unique(self.y)), len(np.unique(self.x))))
|
|
889
|
+
min_redchi2, max_redchi2 = self._plot_contour_map(redchi2_2D, 'Total reduced χ2',
|
|
890
|
+
label_log_scale=True, clim_max=None)
|
|
891
|
+
|
|
892
|
+
self._log_print(f'The minimum value of the total reduced χ2 is: {self._fmt(min_redchi2)}')
|
|
893
|
+
|
|
894
|
+
n_p = np.count_nonzero(self.y == self.y[0])
|
|
895
|
+
min_redchi2_pos = np.where(redchi2_2D == np.nanmin(redchi2_2D))
|
|
896
|
+
min_redchi2_y = np.mean(self.y[min_redchi2_pos[0] * n_p])
|
|
897
|
+
min_redchi2_x = np.mean(self.x[min_redchi2_pos[1]])
|
|
898
|
+
self._log_print(f'The {self.labels[0]} and {self.labels[1]} position of the minimum total reduced χ2 is: {self._fmt(min_redchi2_x)}, {self._fmt(min_redchi2_y)}')
|
|
899
|
+
|
|
900
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
901
|
+
plt.savefig(os.path.join(self.output_dir, "redχ2_tot.pdf"), format='pdf')
|
|
902
|
+
plt.show()
|
|
903
|
+
plt.close()
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# =========================
|
|
908
|
+
# Main calculation methods
|
|
909
|
+
# =========================
|
|
910
|
+
|
|
911
|
+
def Qcmp_elem(self):
|
|
912
|
+
"""Calculate and plot quality factor for each element."""
|
|
913
|
+
Qcmp_elem = np.empty(len(self.data[self.labels[2]]))
|
|
914
|
+
|
|
915
|
+
for i in range(len(self.labels[2:])):
|
|
916
|
+
Model_elem = np.array(self.data[self.labels[i+2]])
|
|
917
|
+
for j in range(len(Model_elem)):
|
|
918
|
+
Qcmp_elem[j] = self.Q_elem(self.apfu_obs[i], Model_elem[j], self.obs_err[i])
|
|
919
|
+
self.plot_elem(Qcmp_elem, i)
|
|
920
|
+
|
|
921
|
+
def Qcmp_phase(self):
|
|
922
|
+
"""Calculate and plot quality factor for each phase."""
|
|
923
|
+
Qcmp_phase_tot = np.empty((len(self.y), len(np.unique(self.phase_id))))
|
|
924
|
+
|
|
925
|
+
for i in np.unique(self.phase_id):
|
|
926
|
+
idx = np.where(self.phase_id == i)[0]+2
|
|
927
|
+
|
|
928
|
+
Model_phase = np.array(self.data[self.labels[idx]])
|
|
929
|
+
apfu_obs_idx = self.apfu_obs[idx-2]
|
|
930
|
+
obs_err_idx = self.obs_err[idx-2]
|
|
931
|
+
idx_phase = np.arange(len(idx))
|
|
932
|
+
Qcmp_phase = np.empty(len(Model_phase))
|
|
933
|
+
|
|
934
|
+
for j in range(len(Model_phase)):
|
|
935
|
+
Qcmp_phase[j] = self.Q_phase(apfu_obs_idx, Model_phase[j, idx_phase], obs_err_idx)
|
|
936
|
+
|
|
937
|
+
self.plot_phase(Qcmp_phase, i)
|
|
938
|
+
Qcmp_phase_tot[:, i-1] = Qcmp_phase
|
|
939
|
+
|
|
940
|
+
return Qcmp_phase_tot
|
|
941
|
+
|
|
942
|
+
def redchi2_phase(self):
|
|
943
|
+
"""Calculate and plot reduced chi-squared for each phase."""
|
|
944
|
+
redchi2_phase_tot = np.empty((len(self.y), len(np.unique(self.phase_id))))
|
|
945
|
+
|
|
946
|
+
for i in np.unique(self.phase_id):
|
|
947
|
+
idx = np.where(self.phase_id == i)[0]+2
|
|
948
|
+
f = len(idx)
|
|
949
|
+
|
|
950
|
+
Model_phase = np.array(self.data[self.labels[idx]])
|
|
951
|
+
apfu_obs_idx = self.apfu_obs[idx-2]
|
|
952
|
+
obs_err_idx = self.obs_err[idx-2]
|
|
953
|
+
idx_phase = np.arange(len(idx))
|
|
954
|
+
redchi2_phase = np.empty(len(Model_phase))
|
|
955
|
+
|
|
956
|
+
if f > 2:
|
|
957
|
+
for j in range(len(Model_phase)):
|
|
958
|
+
redchi2_phase[j] = self.red_chi2(apfu_obs_idx, Model_phase[j, idx_phase], obs_err_idx, f)
|
|
959
|
+
min_redchi2_phase = self.plot_redchi2_phase(redchi2_phase, i, f)
|
|
960
|
+
else:
|
|
961
|
+
for j in range(len(Model_phase)):
|
|
962
|
+
redchi2_phase[j] = self.chi2(apfu_obs_idx, Model_phase[j, idx_phase], obs_err_idx)
|
|
963
|
+
min_redchi2_phase = self.plot_redchi2_phase(redchi2_phase, i, f)
|
|
964
|
+
min_redchi2_phase = 1 + min_redchi2_phase
|
|
965
|
+
if f == 1:
|
|
966
|
+
self._log_print('WARNING: The number of elements in ' + self.phase_name[i-1] + ' is 1, the χ2 may not be accurate')
|
|
967
|
+
|
|
968
|
+
redchi2_phase_tot[:, i-1] = redchi2_phase
|
|
969
|
+
self.min_redchi2 = np.append(self.min_redchi2, min_redchi2_phase)
|
|
970
|
+
|
|
971
|
+
return redchi2_phase_tot
|
|
972
|
+
|
|
973
|
+
def redchi2_tot(self):
|
|
974
|
+
"""Calculate and plot total reduced chi-squared."""
|
|
975
|
+
f = len(self.labels) - 2
|
|
976
|
+
redchi2_tot = np.empty(len(self.data[self.labels[2]]))
|
|
977
|
+
|
|
978
|
+
for i in range(len(self.data[self.labels[2]])):
|
|
979
|
+
Model_tot = np.array(self.data[self.labels[2:]])
|
|
980
|
+
redchi2_tot[i] = self.red_chi2(self.apfu_obs, Model_tot[i], self.obs_err, f)
|
|
981
|
+
|
|
982
|
+
self.plot_redchi2_tot(redchi2_tot)
|
|
983
|
+
|
|
984
|
+
def Qcmp_tot(self, Qcmp_phase_tot, redchi2_phase_tot):
|
|
985
|
+
"""Calculate total quality factor (unweighted)."""
|
|
986
|
+
if self.phase_id is None:
|
|
987
|
+
raise ValueError("phase_id is not set.")
|
|
988
|
+
|
|
989
|
+
weight = np.ones(len(np.unique(self.phase_id)))
|
|
990
|
+
weight_norm = self.norm_weight(weight)
|
|
991
|
+
|
|
992
|
+
Qcmp_allphases = Qcmp_phase_tot @ weight_norm
|
|
993
|
+
|
|
994
|
+
max_Qcmp = np.where(Qcmp_allphases == np.nanmax(Qcmp_allphases))
|
|
995
|
+
Qcmpmax_redchi2_value = redchi2_phase_tot[max_Qcmp[0][0]]
|
|
996
|
+
|
|
997
|
+
self._log_print(f"The reduced χ2 values for the phases at the maximum Q*cmp is: {self._fmt(Qcmpmax_redchi2_value)}")
|
|
998
|
+
|
|
999
|
+
self.plot_tot(Qcmp_allphases, "Unweighted")
|
|
1000
|
+
|
|
1001
|
+
def Qcmp_tot_weight(self, Qcmp_phase_tot, redchi2_phase_tot):
|
|
1002
|
+
"""Calculate total quality factor (weighted)."""
|
|
1003
|
+
self.min_redchi2[self.min_redchi2 < 1] = 1
|
|
1004
|
+
weight = 1 / self.min_redchi2
|
|
1005
|
+
|
|
1006
|
+
self._log_print(f'The weight is: {self._fmt(weight)}')
|
|
1007
|
+
|
|
1008
|
+
weight_norm = self.norm_weight(weight)
|
|
1009
|
+
self._log_print(f'The normalized weight fraction is: {self._fmt(weight_norm)}')
|
|
1010
|
+
|
|
1011
|
+
Qcmp_allphases_weight = Qcmp_phase_tot @ weight_norm
|
|
1012
|
+
|
|
1013
|
+
max_Qcmp = np.where(Qcmp_allphases_weight == np.nanmax(Qcmp_allphases_weight))
|
|
1014
|
+
Qcmpmax_redchi2_value = redchi2_phase_tot[max_Qcmp[0][0]]
|
|
1015
|
+
|
|
1016
|
+
self._log_print(f"The reduced χ2 values for the phases at the maximum Q*cmp are: {self._fmt(Qcmpmax_redchi2_value)}")
|
|
1017
|
+
|
|
1018
|
+
self.plot_tot(Qcmp_allphases_weight, "Weighted")
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
if __name__ == "__main__":
|
|
1022
|
+
QFA = QualityFactorAnalysis()
|
|
1023
|
+
QFA.run_analysis(0)
|