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.
@@ -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)