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/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())