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/parsers.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# parsers.py
|
|
2
|
+
# Enhanced parser with element extraction and stoichiometric coefficient handling
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Sequence, Dict, List, Any, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
logging.getLogger("pyIntersecT.parsers").setLevel(logging.INFO)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _find_case_insensitive_key(columns, target: str) -> Optional[str]:
|
|
15
|
+
"""Find a column name matching target case-insensitively."""
|
|
16
|
+
target_lower = target.strip().lower()
|
|
17
|
+
for c in columns:
|
|
18
|
+
if isinstance(c, str) and c.strip().lower() == target_lower:
|
|
19
|
+
return c
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_element_info(column_name: str) -> Tuple[str, float]:
|
|
24
|
+
"""
|
|
25
|
+
Extract base element name and stoichiometric coefficient from a column name.
|
|
26
|
+
|
|
27
|
+
Handles patterns like:
|
|
28
|
+
- "Al2O3,mol,pfu" -> ("Al", 2.0)
|
|
29
|
+
- "Na2O" -> ("Na", 2.0)
|
|
30
|
+
- "FeO,mol,pfu" -> ("Fe", 1.0) # Note: FeO has coef 1, not 2
|
|
31
|
+
- "MgO,mol,pfu" -> ("Mg", 1.0)
|
|
32
|
+
- "Mg[apfu]" -> ("Mg", 1.0)
|
|
33
|
+
- "K2O[wt%]" -> ("K", 2.0)
|
|
34
|
+
- "Fe" -> ("Fe", 1.0)
|
|
35
|
+
- "CaO" -> ("Ca", 1.0)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Tuple of (element_symbol, stoichiometric_coefficient)
|
|
39
|
+
"""
|
|
40
|
+
# Clean the column name
|
|
41
|
+
col = column_name.strip()
|
|
42
|
+
|
|
43
|
+
# Pattern 1: Element with numeric coefficient followed by O (oxide notation)
|
|
44
|
+
# Examples: Al2O3, Na2O, K2O, Fe2O3
|
|
45
|
+
# Note: FeO, MgO, CaO etc. have implicit coefficient of 1
|
|
46
|
+
pattern_oxide = r'^([A-Z][a-z]?)(\d*)O\d*'
|
|
47
|
+
match = re.match(pattern_oxide, col, re.IGNORECASE)
|
|
48
|
+
if match:
|
|
49
|
+
element = match.group(1)
|
|
50
|
+
coef_str = match.group(2)
|
|
51
|
+
# If no number before O, coefficient is 1 (e.g., FeO, MgO, CaO)
|
|
52
|
+
# If there's a number, that's the coefficient (e.g., Al2O3, Na2O, K2O)
|
|
53
|
+
coef = float(coef_str) if coef_str else 1.0
|
|
54
|
+
return (element, coef)
|
|
55
|
+
|
|
56
|
+
# Pattern 2: Element in brackets or with special notation
|
|
57
|
+
# Examples: Mg[apfu], Fe[wt%], Si(apfu)
|
|
58
|
+
pattern_bracket = r'^([A-Z][a-z]?)\[.*\]|^([A-Z][a-z]?)\(.*\)'
|
|
59
|
+
match = re.match(pattern_bracket, col)
|
|
60
|
+
if match:
|
|
61
|
+
element = match.group(1) or match.group(2)
|
|
62
|
+
return (element, 1.0)
|
|
63
|
+
|
|
64
|
+
# Pattern 3: Just element symbol (possibly followed by non-alphabetic chars)
|
|
65
|
+
# Examples: Mg, Al, Fe, Si
|
|
66
|
+
pattern_simple = r'^([A-Z][a-z]?)(?:[^a-zA-Z]|$)'
|
|
67
|
+
match = re.match(pattern_simple, col)
|
|
68
|
+
if match:
|
|
69
|
+
element = match.group(1)
|
|
70
|
+
return (element, 1.0)
|
|
71
|
+
|
|
72
|
+
# Pattern 4: Element symbol at start (fallback)
|
|
73
|
+
# This catches remaining cases where element is clearly at the start
|
|
74
|
+
if len(col) >= 1 and col[0].isupper():
|
|
75
|
+
# Take first capital letter plus optional lowercase
|
|
76
|
+
element = col[0]
|
|
77
|
+
if len(col) > 1 and col[1].islower():
|
|
78
|
+
element += col[1]
|
|
79
|
+
return (element, 1.0)
|
|
80
|
+
|
|
81
|
+
# No match found - return the original column and coefficient 1
|
|
82
|
+
return (col, 1.0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_element_name(element: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Normalize element names to standard format.
|
|
88
|
+
Handles common variations and returns capitalized element symbol.
|
|
89
|
+
"""
|
|
90
|
+
# Common element name variations
|
|
91
|
+
element_map = {
|
|
92
|
+
'si': 'Si', 'al': 'Al', 'fe': 'Fe', 'mg': 'Mg', 'ca': 'Ca',
|
|
93
|
+
'na': 'Na', 'k': 'K', 'ti': 'Ti', 'mn': 'Mn', 'cr': 'Cr',
|
|
94
|
+
'ni': 'Ni', 'p': 'P', 'o': 'O', 'h': 'H', 'c': 'C',
|
|
95
|
+
'cl': 'Cl', 'f': 'F', 's': 'S', 'ba': 'Ba', 'sr': 'Sr',
|
|
96
|
+
'zn': 'Zn', 'cu': 'Cu', 'co': 'Co', 'v': 'V', 'o2': 'O'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
elem_lower = element.strip().lower()
|
|
100
|
+
return element_map.get(elem_lower, element.capitalize())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _auto_detect_coords_from_columns(columns) -> List[str]:
|
|
104
|
+
"""Auto-detect coordinate columns from available column names."""
|
|
105
|
+
temp_candidates = ["t(k)", "t", "t[°c]", "temperature", "temp"]
|
|
106
|
+
pres_candidates = ["p(bar)", "p[kbar]", "p", "pressure", "press"]
|
|
107
|
+
x_candidates = ["x", "x[0.0-1.0]", "X(C1)", "x(c1)", "Y(CO2)", "y(co2)"]
|
|
108
|
+
|
|
109
|
+
def pick(cands):
|
|
110
|
+
for cand in cands:
|
|
111
|
+
for c in columns:
|
|
112
|
+
if isinstance(c, str) and c.strip().lower() == cand.strip().lower():
|
|
113
|
+
return c
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
t = pick(temp_candidates)
|
|
117
|
+
p = pick(pres_candidates)
|
|
118
|
+
x = pick(x_candidates)
|
|
119
|
+
|
|
120
|
+
# If all three coordinates are present (MAGEMin X-P-T case), return all three
|
|
121
|
+
if t and p and x:
|
|
122
|
+
return [x, p, t] # Order: composition, pressure, temperature
|
|
123
|
+
|
|
124
|
+
if t and p:
|
|
125
|
+
return [t, p]
|
|
126
|
+
if x and p:
|
|
127
|
+
return [x, p]
|
|
128
|
+
if x and t:
|
|
129
|
+
return [x, t]
|
|
130
|
+
|
|
131
|
+
# fallback: first two columns (excluding obvious non-coords)
|
|
132
|
+
cols = [c for c in columns if isinstance(c, str)]
|
|
133
|
+
exclusions = {"phase", "name", "point", "counter"}
|
|
134
|
+
cols = [c for c in cols if c.strip().lower() not in exclusions]
|
|
135
|
+
if len(cols) >= 2:
|
|
136
|
+
return [cols[0], cols[1]]
|
|
137
|
+
# last resort: return raw first two original columns
|
|
138
|
+
return list(columns)[:2]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_table(path: str | Path,
|
|
142
|
+
coord_columns: Optional[Sequence[str]] = None,
|
|
143
|
+
phase_column: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
144
|
+
"""
|
|
145
|
+
Robust parse_table supporting MAGEMin (CSV) and PerpleX (column table).
|
|
146
|
+
coord_columns=None -> automatic detection.
|
|
147
|
+
|
|
148
|
+
Returns list of calculation blocks, each containing:
|
|
149
|
+
- coords: dict of coordinate values
|
|
150
|
+
- phases: list of phase dicts with composition data
|
|
151
|
+
- system: 'magemin' or 'perplex'
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
path = Path(path)
|
|
155
|
+
if not path.exists():
|
|
156
|
+
raise FileNotFoundError(path)
|
|
157
|
+
|
|
158
|
+
# read lines with latin-1 to avoid encoding issues
|
|
159
|
+
text = path.read_text(encoding="latin-1", errors="ignore")
|
|
160
|
+
lines = text.splitlines()
|
|
161
|
+
|
|
162
|
+
# find first non-empty line
|
|
163
|
+
first_idx = next((i for i, L in enumerate(lines) if L.strip()), 0)
|
|
164
|
+
first_line = lines[first_idx] if lines else ""
|
|
165
|
+
|
|
166
|
+
# Heuristic: if first non-empty line contains many commas -> treat as CSV (MAGEMin)
|
|
167
|
+
is_csv_like = first_line.count(",") >= 2
|
|
168
|
+
|
|
169
|
+
df = None
|
|
170
|
+
tried_sep = None
|
|
171
|
+
|
|
172
|
+
if is_csv_like:
|
|
173
|
+
# MAGEMin style: try comma-separated
|
|
174
|
+
try:
|
|
175
|
+
try:
|
|
176
|
+
df = pd.read_csv(path, sep=None, engine="python", encoding="utf-8")
|
|
177
|
+
except Exception as e:
|
|
178
|
+
df = pd.read_csv(path, sep=None, engine="python", encoding="latin1")
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.warning("Failed to read as comma CSV: %s; falling back to whitespace", e)
|
|
182
|
+
df = pd.read_csv(path, sep=r"\s+", engine="python", encoding="latin-1", comment="#")
|
|
183
|
+
tried_sep = "whitespace_fallback"
|
|
184
|
+
else:
|
|
185
|
+
# Try to detect PerpleX header line
|
|
186
|
+
header_idx = None
|
|
187
|
+
for i, L in enumerate(lines):
|
|
188
|
+
if "Name" in L and ("T(" in L or "Counter" in L or "P(" in L or "T[K" in L):
|
|
189
|
+
header_idx = i
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
if header_idx is not None:
|
|
193
|
+
try:
|
|
194
|
+
df = pd.read_csv(path, sep=r"\s+", engine="python", header=header_idx,
|
|
195
|
+
comment="#", encoding="latin-1")
|
|
196
|
+
tried_sep = f"whitespace_with_header@{header_idx}"
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.warning("Failed to read PerpleX-style with header at %d: %s", header_idx, e)
|
|
199
|
+
df = pd.read_csv(path, sep=r"\s+", engine="python", encoding="latin-1", comment="#")
|
|
200
|
+
tried_sep = "whitespace_fallback2"
|
|
201
|
+
else:
|
|
202
|
+
# Unknown: try pandas sniff then whitespace fallback
|
|
203
|
+
try:
|
|
204
|
+
df = pd.read_csv(path, sep=None, engine="python", comment="#", encoding="latin-1")
|
|
205
|
+
tried_sep = "sniff"
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.warning("Failed to auto-detect separator: %s; falling back to whitespace", e)
|
|
208
|
+
df = pd.read_csv(path, sep=r"\s+", engine="python", comment="#", encoding="latin-1")
|
|
209
|
+
tried_sep = "whitespace_final"
|
|
210
|
+
|
|
211
|
+
logger.info("parse_table: used sep=%s, columns=%s", tried_sep, list(df.columns)[:20])
|
|
212
|
+
|
|
213
|
+
# Phase column resolution (case-insensitive)
|
|
214
|
+
inferred_phase_col = None
|
|
215
|
+
if phase_column:
|
|
216
|
+
inferred_phase_col = _find_case_insensitive_key(df.columns, phase_column)
|
|
217
|
+
else:
|
|
218
|
+
inferred_phase_col = (_find_case_insensitive_key(df.columns, "phase") or
|
|
219
|
+
_find_case_insensitive_key(df.columns, "Name"))
|
|
220
|
+
|
|
221
|
+
if inferred_phase_col is None:
|
|
222
|
+
preview_headers = list(df.columns)
|
|
223
|
+
sample_lines = "\n".join(lines[first_idx:first_idx+5])
|
|
224
|
+
raise KeyError(
|
|
225
|
+
f"Phase column not found. Available columns: {preview_headers}\n"
|
|
226
|
+
f"First non-empty lines of file:\n{sample_lines}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Coordinate columns detection or mapping
|
|
230
|
+
if coord_columns is None:
|
|
231
|
+
coord_cols = _auto_detect_coords_from_columns(df.columns)
|
|
232
|
+
logger.info("Auto-detected coordinate columns: %s", coord_cols)
|
|
233
|
+
else:
|
|
234
|
+
coord_cols = []
|
|
235
|
+
for c in coord_columns:
|
|
236
|
+
actual = _find_case_insensitive_key(df.columns, c)
|
|
237
|
+
if actual is None:
|
|
238
|
+
raise KeyError(f"Coordinate column '{c}' not found. Available: {list(df.columns)}")
|
|
239
|
+
coord_cols.append(actual)
|
|
240
|
+
|
|
241
|
+
# Group rows into calculation blocks
|
|
242
|
+
blocks: Dict[Tuple[Tuple[str, float], ...], Dict[str, Any]] = {}
|
|
243
|
+
|
|
244
|
+
for idx, row in df.iterrows():
|
|
245
|
+
coords: Dict[str, float] = {}
|
|
246
|
+
skip_row = False
|
|
247
|
+
|
|
248
|
+
for col_name in coord_cols:
|
|
249
|
+
try:
|
|
250
|
+
coords[col_name] = float(row[col_name])
|
|
251
|
+
except Exception:
|
|
252
|
+
skip_row = True
|
|
253
|
+
break
|
|
254
|
+
|
|
255
|
+
if skip_row:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
key = tuple(sorted((c, coords[c]) for c in coords))
|
|
259
|
+
if key not in blocks:
|
|
260
|
+
system = "magemin" if is_csv_like else "perplex"
|
|
261
|
+
blocks[key] = {
|
|
262
|
+
"coords": coords.copy(),
|
|
263
|
+
"phases": [],
|
|
264
|
+
"system": system
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
phase_entry = {"phase": row[inferred_phase_col]}
|
|
268
|
+
|
|
269
|
+
# Process columns and filter for composition data only
|
|
270
|
+
for col in df.columns:
|
|
271
|
+
if col == inferred_phase_col or col in coord_cols:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# Filter: only process columns that represent chemical compositions
|
|
275
|
+
# These are typically oxide columns ending with O, O2, O3, etc. followed by modifiers
|
|
276
|
+
# Examples: Na2O,mol,pfu, MgO,mol,pfu, Al2O3,mol,pfu, SiO2,mol,pfu, FeO,mol,pfu
|
|
277
|
+
# Skip thermodynamic properties like alpha,1/K, beta,1/bar, cp,J/K/mol, etc.
|
|
278
|
+
|
|
279
|
+
col_lower = col.lower()
|
|
280
|
+
|
|
281
|
+
# Explicitly skip known non-composition columns
|
|
282
|
+
skip_patterns = [
|
|
283
|
+
'alpha', 'beta', 'gamma', 'delta', # Greek letters for properties
|
|
284
|
+
'cp,', 'cv,', 'entropy', 's,j/', # Heat capacity and entropy
|
|
285
|
+
'gruneisen', 'ks,', 'gs,', 'bulk', # Elastic properties
|
|
286
|
+
'v,j/', 'h,j/', 'g,j/', # Thermodynamic potentials
|
|
287
|
+
'vp,', 'vs,', 'v0,', 'vp/', 'rho,', # Seismic and density
|
|
288
|
+
'counter', 'n,mol', 'n,g', # Counters and totals
|
|
289
|
+
'mu[', 'wt,%', 'vol,%', 'mol,%', # Chemical potentials and percentages (not apfu)
|
|
290
|
+
'nom_ox' # Nominal oxide (not composition)
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
should_skip = any(pattern in col_lower for pattern in skip_patterns)
|
|
294
|
+
if should_skip:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
# Only process columns that look like oxide compositions
|
|
298
|
+
# Must contain 'O' (for oxide) and ideally 'mol' or 'pfu' or 'apfu'
|
|
299
|
+
is_oxide = 'o' in col_lower and ('mol' in col_lower or 'pfu' in col_lower or 'apfu' in col_lower)
|
|
300
|
+
|
|
301
|
+
# Also allow simple element names followed by [apfu] or similar
|
|
302
|
+
is_element_apfu = '[apfu]' in col_lower or '(apfu)' in col_lower
|
|
303
|
+
|
|
304
|
+
if not (is_oxide or is_element_apfu):
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
val = row[col]
|
|
308
|
+
try:
|
|
309
|
+
num = float(val)
|
|
310
|
+
# Extract element info from column name
|
|
311
|
+
element, coef = extract_element_info(col)
|
|
312
|
+
element_normalized = normalize_element_name(element)
|
|
313
|
+
|
|
314
|
+
# Ferric iron (Fe2O3) is stored as Fe3, so that it does not overwrite the ferrous iron (FeO) stored as Fe
|
|
315
|
+
if col_lower.startswith("fe2o3"):
|
|
316
|
+
element_normalized = "Fe3"
|
|
317
|
+
|
|
318
|
+
# Store both raw column name and processed element
|
|
319
|
+
phase_entry[col] = num # Keep original column
|
|
320
|
+
|
|
321
|
+
# Also store with normalized element name and applied coefficient
|
|
322
|
+
if coef != 1.0:
|
|
323
|
+
# Apply stoichiometric coefficient for oxides
|
|
324
|
+
phase_entry[f"{element_normalized}_apfu"] = num * coef
|
|
325
|
+
else:
|
|
326
|
+
phase_entry[f"{element_normalized}_apfu"] = num
|
|
327
|
+
|
|
328
|
+
except Exception:
|
|
329
|
+
# Ignore non-numeric fields
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
# Total iron (Fe2+ + Fe3+), available as Fetot when Fe2O3 is a component
|
|
333
|
+
if "Fe_apfu" in phase_entry and "Fe3_apfu" in phase_entry:
|
|
334
|
+
phase_entry["Fetot_apfu"] = phase_entry["Fe_apfu"] + phase_entry["Fe3_apfu"]
|
|
335
|
+
|
|
336
|
+
blocks[key]["phases"].append(phase_entry)
|
|
337
|
+
|
|
338
|
+
logger.info("Parsed %d calculation points with %d total phases",
|
|
339
|
+
len(blocks), sum(len(b["phases"]) for b in blocks.values()))
|
|
340
|
+
|
|
341
|
+
return list(blocks.values())
|