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.
Binary file
File without changes
@@ -0,0 +1,447 @@
1
+ """
2
+ mineral_aliases.py
3
+
4
+ Robust loader / resolver for mineral name aliases + solvus discrimination.
5
+ """
6
+
7
+ # TOML: prefer tomllib (Py>=3.11), fallback to tomli for older Pythons
8
+ try:
9
+ import tomllib # Python 3.11+
10
+ except Exception:
11
+ import tomli as tomllib # type: ignore
12
+
13
+ from pathlib import Path
14
+ import logging
15
+ import numpy as np
16
+ from typing import Dict, Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+ logging.getLogger("pyIntersecT.mineral_aliases").setLevel(logging.INFO)
20
+
21
+ # Cache for normalized endmember arrays:
22
+ _NORMALIZED_ENDMEMBERS_ARRAY: Dict[Any, Any] = {}
23
+
24
+
25
+ class MineralAliases:
26
+ """
27
+ Load mineral aliases from a TOML and resolve phase names case-insensitively.
28
+
29
+ toml_path: path to mineral_symbols.toml which should contain sections
30
+ like ["MAGEMin to Warr2021"] and ["PerpleX to Warr2021"] mapping names.
31
+ """
32
+
33
+ def __init__(self, toml_path, strict: bool = True, persist_unknowns: bool = False):
34
+ self.strict = strict
35
+ self.persist_unknowns = persist_unknowns
36
+
37
+ # store mappings with normalized (lower().strip()) keys
38
+ self.magemin: Dict[str, str] = {}
39
+ self.perplex: Dict[str, str] = {}
40
+
41
+ self._load(toml_path)
42
+
43
+ def _load(self, toml_path):
44
+ toml_path = Path(toml_path)
45
+ if not toml_path.exists():
46
+ raise FileNotFoundError(f"mineral_symbols TOML not found: {toml_path}")
47
+
48
+ with open(toml_path, "rb") as f:
49
+ data = tomllib.load(f)
50
+
51
+ for section_name, mapping in data.items():
52
+ section_lower = section_name.lower()
53
+ if "magemin" in section_lower:
54
+ for k, v in mapping.items():
55
+ if isinstance(k, str):
56
+ self.magemin[k.strip().lower()] = v
57
+ elif "perple" in section_lower or "perplex" in section_lower:
58
+ for k, v in mapping.items():
59
+ if isinstance(k, str):
60
+ self.perplex[k.strip().lower()] = v
61
+
62
+ logger.debug("Loaded %d magemin aliases, %d perplex aliases",
63
+ len(self.magemin), len(self.perplex))
64
+
65
+ def resolve(self, phase_name: str, system: str) -> str:
66
+ """
67
+ Resolve phase_name to canonical name using the chosen system ('magemin'|'perplex').
68
+ Case-insensitive. In non-strict mode unknowns are returned as-is (and optionally persisted).
69
+ """
70
+ if not isinstance(phase_name, str):
71
+ raise TypeError("phase_name must be a string")
72
+
73
+ key = phase_name.strip().lower()
74
+ system = system.strip().lower()
75
+
76
+ if system == "magemin":
77
+ table = self.magemin
78
+ elif system == "perplex" or system == "perple_x":
79
+ table = self.perplex
80
+ else:
81
+ raise ValueError(f"Unknown system: {system!r}")
82
+
83
+ if key in table:
84
+ return table[key]
85
+
86
+ if self.strict:
87
+ raise KeyError(f"Phase '{phase_name}' not found in {system} dictionary")
88
+
89
+ # non-strict: return original (or persist a mapping to itself)
90
+ if self.persist_unknowns:
91
+ table[key] = phase_name
92
+ return phase_name
93
+
94
+
95
+ # -------------------------
96
+ # Endmember compositions and solvus discrimination
97
+ # -------------------------
98
+ ENDMEMBER_COMPOSITIONS = {
99
+ "Mca": {
100
+ "Pg": {"Na": 1, "K": 0, "Al": 3, "Si": 3}, # Paragonite (Na-rich)
101
+ "Ms": {"Na": 0, "K": 1, "Al": 3, "Si": 3}, # Muscovite (K-rich)
102
+ "Mrg": {"Ca": 1, "Al": 4, "Si": 2}, # Margarite (Ca-rich)
103
+ },
104
+ "Fsp": {
105
+ "Ab": {"Na": 1, "Al": 1, "Si": 3}, # Albite
106
+ "An": {"Ca": 1, "Al": 2, "Si": 2}, # Anorthite
107
+ "Or": {"K": 1, "Al": 1, "Si": 3}, # Orthoclase
108
+ },
109
+ "Cpx": {
110
+ "Di": {"Ca": 1, "Mg": 1, "Si": 2}, # Diopside (high-Ca, Mg-rich)
111
+ "Hd": {"Ca": 1, "Fe": 1, "Si": 2}, # Hedenbergite (high-Ca, Fe-rich)
112
+ "Aug": {"Ca": 0.7, "Mg": 0.7, "Fe": 0.3, "Al": 0.3, "Si": 2}, # Augite (intermediate-Ca)
113
+ "Pig": {"Ca": 0.1, "Mg": 0.9, "Fe": 0.1, "Si": 2}, # Pigeonite (low-Ca, Mg-rich)
114
+ "Jd": {"Na": 1, "Al": 1, "Si": 2}, # Jadeite (Na-Al endmember)
115
+ "Aeg": {"Na": 1, "Fe": 1, "Si": 2}, # Aegirine (Na-Fe endmember)
116
+ },
117
+ "Amp": {
118
+ "Tr": {"Ca": 2, "Mg": 5, "Si": 8}, # Tremolite (calcic, Mg-rich)
119
+ "Act": {"Ca": 2, "Mg": 3, "Fe": 2, "Si": 8}, # Actinolite (calcic, intermediate Mg-Fe)
120
+ "Gln": {"Na": 2, "Mg": 3, "Al": 2, "Si": 8}, # Glaucophane (sodic, Al-bearing)
121
+ "Cum": {"Mg": 5, "Fe": 2, "Si": 8}, # Cummingtonite (Ca-poor, Mg-Fe amphibole)
122
+ },
123
+ "Spl": {
124
+ "Chr": {"Fe": 1, "Cr": 2}, # Chromite (Cr-rich spinel)
125
+ "Usp": {"Fe": 2, "Ti": 1}, # Ulvöspinel-like (Ti-bearing spinel)
126
+ "Mag": {"Fe": 3}, # Magnetite-like (Fe-dominant)
127
+ },
128
+ "Ilm": {
129
+ "Ilm": {"Fe": 1, "Ti": 1}, # Ilmenite (Fe-Ti oxide)
130
+ "Hem": {"Fe": 2}, # Hematite (Fe2O3)
131
+ },
132
+ "Carb": {
133
+ "Cal": {"Ca": 1}, # Calcite (Ca-only carbonate)
134
+ "Dol": {"Ca": 1, "Mg": 1}, # Dolomite (Ca-Mg carbonate)
135
+ "Ank": {"Ca": 1, "Fe": 1}, # Ankerite (Ca-Fe carbonate)
136
+ "Sid": {"Fe": 1}, # Siderite (Fe carbonate)
137
+ },
138
+ }
139
+
140
+ ENDMEMBER_DISTANCE_THRESHOLD = {
141
+ k: 0.3 for k in ENDMEMBER_COMPOSITIONS.keys()
142
+ }
143
+ # Stricter threshold for clinopyroxenes due to more endmembers
144
+ ENDMEMBER_DISTANCE_THRESHOLD["Cpx"] = 0.35
145
+
146
+ def _get_element_value(phase: dict, element: str) -> float:
147
+ """Get element value from phase dict, trying multiple key patterns."""
148
+
149
+ # Endmember classification uses total iron (Fe2+ + Fe3+) when available
150
+ if element == "Fe" and "Fetot_apfu" in phase:
151
+ return float(phase["Fetot_apfu"])
152
+
153
+ # Try simple element name first
154
+ if element in phase:
155
+ val = phase[element]
156
+ if val is not None:
157
+ try:
158
+ return float(val)
159
+ except (ValueError, TypeError):
160
+ pass
161
+
162
+ # Try with _apfu suffix
163
+ key_apfu = f"{element}_apfu"
164
+ if key_apfu in phase:
165
+ val = phase[key_apfu]
166
+ if val is not None:
167
+ try:
168
+ return float(val)
169
+ except (ValueError, TypeError):
170
+ pass
171
+
172
+ return 0.0
173
+
174
+ def discriminate_solvus(phases, system: str = "perplex"):
175
+ """
176
+ Group phases by parent phase name and apply endmember classification.
177
+ For Perple_X output, applies compositional discrimination to identify
178
+ specific endmember compositions within solid solution series.
179
+ For MAGEMin output, solvus discrimination is skipped as MAGEMin already
180
+ handles phase separation internally.
181
+
182
+ Returns:
183
+ Tuple of (processed_phases, transformation_stats)
184
+ where transformation_stats is dict: {(parent, assigned): count}
185
+ """
186
+ if system.lower() == "magemin":
187
+ transformations = {}
188
+ for p in phases:
189
+ name = p.get("phase", "")
190
+ key = (name, name)
191
+ transformations[key] = transformations.get(key, 0) + 1
192
+ return phases, transformations
193
+
194
+ grouped = {}
195
+ for p in phases:
196
+ name = p.get("phase", "")
197
+ grouped.setdefault(name, []).append(p)
198
+
199
+ output = []
200
+ transformations = {} # Track (original_name, assigned_name) -> count
201
+
202
+ for parent_name, group in grouped.items():
203
+ parent_key = None
204
+ for k in ENDMEMBER_COMPOSITIONS:
205
+ if k.lower() == parent_name.lower():
206
+ parent_key = k
207
+ break
208
+
209
+ if parent_key is None:
210
+ logger.debug("No endmember rules for phase '%s', keeping as-is", parent_name)
211
+ output.extend(group)
212
+ # Count unchanged phases
213
+ for phase in group:
214
+ key = (parent_name, parent_name)
215
+ transformations[key] = transformations.get(key, 0) + 1
216
+ continue
217
+
218
+ logger.debug("Applying endmember classification to %d phase(s) of '%s'", len(group), parent_name)
219
+
220
+ if parent_key == "Cpx":
221
+ logger.debug(" Using omphacite heuristic for Cpx phases")
222
+ discriminated = _discriminate_cpx(group)
223
+ output.extend(discriminated)
224
+ # Count transformations
225
+ for phase in discriminated:
226
+ assigned = phase.get("phase")
227
+ key = (parent_name, assigned)
228
+ transformations[key] = transformations.get(key, 0) + 1
229
+ continue
230
+
231
+ # Special handling for feldspars
232
+ if parent_key == "Fsp":
233
+ logger.debug(" Using Ca-alkali criteria for Fsp discrimination")
234
+ for phase in group:
235
+ phase["orig_phase"] = phase.get("phase")
236
+
237
+ na = _get_element_value(phase, "Na")
238
+ ca = _get_element_value(phase, "Ca")
239
+ k = _get_element_value(phase, "K")
240
+
241
+ total_alkali = na + ca + k
242
+ if total_alkali <= 0:
243
+ assigned_name = "Fsp"
244
+ elif ca / total_alkali > 0.10:
245
+ assigned_name = "Pl"
246
+ elif na > k:
247
+ assigned_name = "Pl"
248
+ else:
249
+ assigned_name = "Afs"
250
+
251
+ phase["phase"] = assigned_name
252
+ key = (parent_name, assigned_name)
253
+ transformations[key] = transformations.get(key, 0) + 1
254
+ output.append(phase)
255
+ continue
256
+
257
+ # Standard endmember distance-based discrimination for ALL phases
258
+ threshold = ENDMEMBER_DISTANCE_THRESHOLD.get(parent_key, 0.3)
259
+ for idx, phase in enumerate(group):
260
+ phase["orig_phase"] = phase.get("phase")
261
+ best_em, best_dist = _find_nearest_endmember(phase, parent_key)
262
+
263
+ logger.debug(" Phase %d/%d: nearest endmember='%s', distance=%.4f (threshold=%.2f)",
264
+ idx+1, len(group), best_em, best_dist, threshold)
265
+
266
+ if best_dist <= threshold:
267
+ old_name = phase["phase"]
268
+ phase["phase"] = best_em
269
+ logger.debug(" Assigned '%s' -> '%s' (distance %.4f <= threshold %.2f)",
270
+ old_name, best_em, best_dist, threshold)
271
+ # Count transformation
272
+ key = (parent_name, best_em)
273
+ transformations[key] = transformations.get(key, 0) + 1
274
+ else:
275
+ logger.debug(" Keeping original name '%s' (distance %.4f > threshold %.2f)",
276
+ phase["phase"], best_dist, threshold)
277
+ # Count unchanged
278
+ key = (parent_name, parent_name)
279
+ transformations[key] = transformations.get(key, 0) + 1
280
+ output.append(phase)
281
+
282
+ return output, transformations
283
+
284
+
285
+ def _discriminate_cpx(cpx_group):
286
+ """
287
+ Discriminate clinopyroxenes using compositional heuristics.
288
+
289
+ Classifies clinopyroxenes based on the standard nomenclature following
290
+ Morimoto et al. (1988) pyroxene classification scheme. Recognizes six
291
+ endmember types plus omphacite as an intermediate composition and a generic Cpx category for compositions that do not fit well:
292
+
293
+ - Jd (jadeite): Na-Al pyroxene, very high jadeite component (>0.8), very low Ca (<0.15)
294
+ - Omp (omphacite): Intermediate Di-Jd solid solution, significant jadeite component (>0.25)
295
+ - Acm (aegirine): Na-Fe pyroxene, high Na, low Ca, Fe-dominant
296
+ - Di (diopside): High-Ca Mg-rich pyroxene (Ca>0.5, Mg#>0.6)
297
+ - Hd (hedenbergite): High-Ca Fe-rich pyroxene (Ca>0.5, Mg#<0.4)
298
+ - Aug (augite): Intermediate-Ca pyroxene (0.2<Ca<0.5)
299
+ - Pig (pigeonite): Low-Ca pyroxene (0.05<Ca<0.2)
300
+ - Cpx (generic): Compositions not matching specific endmember criteria
301
+
302
+ Returns list of phases with updated names.
303
+ """
304
+ output = []
305
+
306
+ for phase in cpx_group:
307
+ phase["orig_phase"] = phase.get("phase")
308
+
309
+ # Get cation abundances
310
+ na = _get_element_value(phase, "Na")
311
+ al = _get_element_value(phase, "Al")
312
+ ca = _get_element_value(phase, "Ca")
313
+ mg = _get_element_value(phase, "Mg")
314
+ fe = _get_element_value(phase, "Fe")
315
+
316
+ logger.debug(" Cpx composition: Ca=%.3f, Mg=%.3f, Fe=%.3f, Na=%.3f, Al=%.3f",
317
+ ca, mg, fe, na, al)
318
+
319
+ # Calculate diagnostic ratios
320
+ total_cations = ca + mg + fe + na + al
321
+ if total_cations <= 0:
322
+ phase["phase"] = "Cpx"
323
+ logger.debug(" No cation data available, keeping as 'Cpx'")
324
+ output.append(phase)
325
+ continue
326
+
327
+ # Normalized fractions
328
+ jd_component = (na + al) / total_cations # Jadeite component
329
+ ca_frac = ca / total_cations # Calcium content
330
+ na_frac = na / total_cations # Sodium content
331
+ mg_number = mg / (mg + fe) if (mg + fe) > 0 else 0.0 # Mg/(Mg+Fe)
332
+
333
+ # Hierarchical classification based on standard pyroxene nomenclature
334
+
335
+ # First branch: Sodic pyroxenes (high Na+Al content)
336
+
337
+ # 1) Jadeite: Pure sodic pyroxene with very high jadeite component and minimal Ca
338
+ # Corresponds to compositions near the Jd apex in the Jd-Ae-Di+Hd diagram
339
+ if jd_component > 0.8 and ca_frac < 0.15:
340
+ assigned_name = "Jd"
341
+ logger.debug(" Assigned 'Jd' (jadeite): jd_component=%.3f, ca_frac=%.3f",
342
+ jd_component, ca_frac)
343
+
344
+ # 2) Omphacite: Jadeite with significant Ca, representing Di-Jd solid solution
345
+ # Occupies the region between pure jadeite and the diopside-hedenbergite join
346
+ # Critical for high-pressure metamorphic assemblages (eclogites)
347
+ elif jd_component > 0.25:
348
+ assigned_name = "Omp"
349
+ logger.debug(" Assigned 'Omp' (omphacite): jd_component=%.3f, ca_frac=%.3f",
350
+ jd_component, ca_frac)
351
+
352
+ # 3) Aegirine: Sodic Fe-rich pyroxene with high Na, low Ca, Fe-dominant
353
+ # Forms solid solution toward aegirine-augite with increasing Ca
354
+ elif na_frac > 0.4 and ca_frac < 0.25 and mg_number < 0.3:
355
+ assigned_name = "Acm"
356
+ logger.debug(" Assigned 'Acm' (aegirine): na_frac=%.3f, ca_frac=%.3f, mg_number=%.3f",
357
+ na_frac, ca_frac, mg_number)
358
+
359
+ # Second branch: Non-sodic pyroxenes (low Na+Al, classified by Ca content)
360
+
361
+ # 4) Diopside: High-Ca, Mg-rich endmember
362
+ # Requires Ca > 0.5 (>50% of M2 site) and high Mg# characteristic of diopside
363
+ elif ca_frac > 0.5 and mg_number > 0.6:
364
+ assigned_name = "Di"
365
+ logger.debug(" Assigned 'Di' (diopside): ca_frac=%.3f, mg_number=%.3f",
366
+ ca_frac, mg_number)
367
+
368
+ # 5) Hedenbergite: High-Ca, Fe-rich endmember
369
+ # Requires Ca > 0.5 and Fe-dominant composition
370
+ elif ca_frac > 0.5 and mg_number < 0.4:
371
+ assigned_name = "Hd"
372
+ logger.debug(" Assigned 'Hd' (hedenbergite): ca_frac=%.3f, mg_number=%.3f",
373
+ ca_frac, mg_number)
374
+
375
+ # 6) Augite: Intermediate-Ca pyroxene
376
+ # Standard definition: 20-45% Ca in M2 site (Wo20-45 in Wo-En-Fs diagram)
377
+ # Characteristic of many igneous and metamorphic rocks
378
+ elif 0.2 <= ca_frac <= 0.5:
379
+ assigned_name = "Aug"
380
+ logger.debug(" Assigned 'Aug' (augite): ca_frac=%.3f, mg_number=%.3f",
381
+ ca_frac, mg_number)
382
+
383
+ # 7) Pigeonite: Low-Ca pyroxene
384
+ # Standard definition: 5-20% Ca in M2 site (Wo5-20)
385
+ # Monoclinic structure distinguishes it from orthopyroxene (Wo < 5)
386
+ # Common in rapidly cooled igneous rocks and some metamorphic assemblages
387
+ elif 0.05 <= ca_frac < 0.2:
388
+ assigned_name = "Pig"
389
+ logger.debug(" Assigned 'Pig' (pigeonite): ca_frac=%.3f, mg_number=%.3f",
390
+ ca_frac, mg_number)
391
+
392
+ # 8) Generic Cpx: Very low Ca (<5%) or compositions not matching criteria
393
+ # Very low Ca compositions (Ca < 0.05) approach orthopyroxene field
394
+ # but retain monoclinic structure, hence generic Cpx designation
395
+ else:
396
+ assigned_name = "Cpx"
397
+ logger.debug(" Kept as 'Cpx' (intermediate/low-Ca composition): jd_comp=%.3f, ca_frac=%.3f, mg_number=%.3f",
398
+ jd_component, ca_frac, mg_number)
399
+
400
+ phase["phase"] = assigned_name
401
+ output.append(phase)
402
+
403
+ return output
404
+
405
+
406
+ def _find_nearest_endmember(phase: dict, parent_name: str):
407
+ endmembers = ENDMEMBER_COMPOSITIONS[parent_name]
408
+
409
+ phase_elems = set()
410
+ for sto in endmembers.values():
411
+ phase_elems |= set(sto.keys())
412
+ elems = tuple(sorted(phase_elems))
413
+
414
+ key = (parent_name, elems)
415
+ cached = _NORMALIZED_ENDMEMBERS_ARRAY.get(key)
416
+ if cached is not None:
417
+ em_names, em_array = cached
418
+ else:
419
+ em_names = list(endmembers.keys())
420
+ em_vecs = []
421
+ for nm in em_names:
422
+ sto = endmembers[nm]
423
+ vec = np.array([sto.get(e, 0.0) for e in elems], dtype=float)
424
+ total = vec.sum()
425
+ if total <= 0:
426
+ total = 1.0
427
+ vec = vec / total
428
+ em_vecs.append(vec)
429
+ em_array = np.vstack(em_vecs)
430
+ _NORMALIZED_ENDMEMBERS_ARRAY[key] = (em_names, em_array)
431
+
432
+ p_vec = np.array([_get_element_value(phase, e) for e in elems], dtype=float)
433
+ p_total = p_vec.sum()
434
+ if p_total <= 0:
435
+ p_total = 1.0
436
+ p_vec = p_vec / p_total
437
+
438
+ diffs = em_array - p_vec
439
+ dists = np.linalg.norm(diffs, axis=1)
440
+ idx = int(dists.argmin())
441
+
442
+ phase_comp_str = ", ".join([f"{e}={_get_element_value(phase, e):.3f}" for e in elems if _get_element_value(phase, e) > 0.001])
443
+ logger.debug(" Phase composition (raw): %s", phase_comp_str)
444
+ logger.debug(" Distances to endmembers: %s",
445
+ ", ".join([f"{em_names[i]}={dists[i]:.4f}" for i in range(len(em_names))]))
446
+
447
+ return em_names[idx], float(dists[idx])