-
Notifications
You must be signed in to change notification settings - Fork 0
Jake update #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Jake update #51
Changes from 3 commits
22b731b
246c26a
aec37f9
deaaf96
b8dbb4f
6e58f24
8c52391
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,331 @@ | ||
| ######################################################. | ||
| # This file stores the mlip class # | ||
| ######################################################. | ||
|
|
||
| import datetime | ||
| import shlex | ||
| from pathlib import Path | ||
|
|
||
| import numpy as np | ||
| from rdkit import Chem | ||
|
|
||
| from moldscript.utils import eV_to_hartree | ||
|
|
||
|
|
||
| class mlip: | ||
| """ | ||
| Parse MACE-Polar extxyz files into a dictionary compatible with get_df. | ||
|
|
||
| Expected folder layout: | ||
| - neutral/*.extxyz | ||
| - reduced/*.extxyz (optional) | ||
| - oxidized/*.extxyz (optional) | ||
|
|
||
| The generated dictionary follows the same structure used by the other | ||
| modules: { | ||
| "CPU_time": [], | ||
| "<name>": { | ||
| "mol": {...}, | ||
| "atom": {...}, | ||
| "bond": {...}, | ||
| "CPU_time": datetime.timedelta(...) | ||
| } | ||
| } | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| neutral, | ||
| reduced="", | ||
| oxidized="", | ||
| data_dict=None, | ||
| atom_charge_property="q_mace_polar", | ||
| file_glob="*.extxyz", | ||
| ): | ||
| self.neutral = Path(neutral) | ||
| self.reduced = Path(reduced) if reduced else None | ||
| self.oxidized = Path(oxidized) if oxidized else None | ||
| self.file_glob = file_glob | ||
| self.atom_charge_property = atom_charge_property | ||
| self.ptable = Chem.GetPeriodicTable() | ||
|
|
||
| self._progress_total = self._count_input_files() | ||
| self._progress_done = 0 | ||
| self._progress_step = 0 | ||
|
|
||
| print("-- MLIP Parameter Collection starting", flush=True) | ||
|
|
||
| if data_dict is None or data_dict == {}: | ||
| self.data_dict = {"CPU_time": []} | ||
| else: | ||
| self.data_dict = data_dict | ||
| self.data_dict.setdefault("CPU_time", []) | ||
|
|
||
| # key -> {"neutral": rec, "reduced": rec, "oxidized": rec} | ||
| self.state_records = {} | ||
|
|
||
| self._parse_state_dir(self.neutral, "neutral") | ||
| if self.reduced is not None: | ||
| self._parse_state_dir(self.reduced, "reduced") | ||
| if self.oxidized is not None: | ||
| self._parse_state_dir(self.oxidized, "oxidized") | ||
|
|
||
| self.file_data = self._build_data_dict() | ||
| print("-- MLIP Parameter Collection complete", flush=True) | ||
|
|
||
| def _count_input_files(self): | ||
| total = len(list(self.neutral.glob(self.file_glob))) | ||
| if self.reduced is not None: | ||
| total += len(list(self.reduced.glob(self.file_glob))) | ||
| if self.oxidized is not None: | ||
| total += len(list(self.oxidized.glob(self.file_glob))) | ||
| return total | ||
|
|
||
| def _report_progress(self): | ||
| if self._progress_total == 0: | ||
| return | ||
| percent = int((self._progress_done / self._progress_total) * 100) | ||
| step = percent // 5 | ||
| if step > self._progress_step: | ||
| for marker in range(self._progress_step + 1, step + 1): | ||
| print( | ||
| f"Progress: {marker * 5}% ({self._progress_done}/{self._progress_total})", | ||
| flush=True, | ||
| ) | ||
| self._progress_step = step | ||
|
|
||
| def _parse_state_dir(self, state_dir, state): | ||
| files = sorted(state_dir.glob(self.file_glob)) | ||
| total = len(files) | ||
| for file_path in files: | ||
| record = self._read_extxyz(file_path) | ||
| key = self._canonical_key(record["header"], file_path) | ||
| if key not in self.state_records: | ||
| self.state_records[key] = {} | ||
| self.state_records[key][state] = record | ||
| self._progress_done += 1 | ||
| self._report_progress() | ||
| if total == 0: | ||
| print(f"! No MLIP files found in {state_dir}", flush=True) | ||
|
|
||
| def _build_data_dict(self): | ||
| total = len(self.state_records) | ||
| if total == 0: | ||
| print("x Could not find MLIP files to obtain information", flush=True) | ||
| return self.data_dict | ||
|
|
||
| for key, states in self.state_records.items(): | ||
| base_state = self._pick_base_state(states) | ||
| base_record = states[base_state] | ||
|
|
||
| symbols = base_record["symbols"] | ||
| coords = np.array(base_record["coords"], dtype=float) | ||
| atomnos = np.array([self.ptable.GetAtomicNumber(sym) for sym in symbols], dtype=int) | ||
|
|
||
| entry = { | ||
| "mol": {}, | ||
| "atom": {}, | ||
| "bond": {}, | ||
| "CPU_time": datetime.timedelta(0), | ||
| } | ||
|
|
||
| entry["atom"]["atomnos"] = atomnos | ||
| entry["bond"]["bond_length"] = self._bond_length_matrix(coords) | ||
|
|
||
| entry["mol"].setdefault("smiles", "") | ||
|
|
||
| # State energies | ||
| e_neutral_ev = self._state_energy_ev(states.get("neutral")) | ||
| e_reduced_ev = self._state_energy_ev(states.get("reduced")) | ||
| e_oxidized_ev = self._state_energy_ev(states.get("oxidized")) | ||
|
|
||
| if np.isfinite(e_neutral_ev): | ||
| entry["mol"]["scfenergy"] = e_neutral_ev * eV_to_hartree | ||
|
|
||
| else: | ||
| entry["mol"]["scfenergy"] = np.nan | ||
|
|
||
| if np.isfinite(e_oxidized_ev) and np.isfinite(e_neutral_ev): | ||
| ie_h = (e_oxidized_ev - e_neutral_ev) * eV_to_hartree | ||
| entry["mol"]["vertical_ie"] = ie_h | ||
| entry["mol"]["ionization_potential"] = ie_h | ||
| else: | ||
| entry["mol"]["vertical_ie"] = np.nan | ||
| entry["mol"]["ionization_potential"] = np.nan | ||
|
|
||
| if np.isfinite(e_reduced_ev) and np.isfinite(e_neutral_ev): | ||
| ea_h = (e_reduced_ev - e_neutral_ev) * eV_to_hartree | ||
| entry["mol"]["vertical_ea"] = ea_h | ||
| entry["mol"]["electron_affinity"] = ea_h | ||
| else: | ||
| entry["mol"]["vertical_ea"] = np.nan | ||
| entry["mol"]["electron_affinity"] = np.nan | ||
|
|
||
| # Dipole from the first state that provides it (neutral preferred). | ||
| dip = self._dipole_vector(states) | ||
| entry["mol"]["dipole_x"] = dip[0] | ||
| entry["mol"]["dipole_y"] = dip[1] | ||
| entry["mol"]["dipole_z"] = dip[2] | ||
| entry["mol"]["dipole"] = float(np.linalg.norm(dip)) if np.all(np.isfinite(dip)) else np.nan | ||
|
|
||
| q_neutral = self._state_charges(states.get("neutral"), len(atomnos)) | ||
| q_reduced = self._state_charges(states.get("reduced"), len(atomnos)) | ||
| q_oxidized = self._state_charges(states.get("oxidized"), len(atomnos)) | ||
|
|
||
| # Follow sign convention already used in fukui.py | ||
| if np.all(np.isfinite(q_neutral)) and np.all(np.isfinite(q_reduced)): | ||
| fplus = -1.0 * (q_reduced - q_neutral) | ||
| else: | ||
| fplus = np.full(len(atomnos), np.nan) | ||
|
|
||
| if np.all(np.isfinite(q_neutral)) and np.all(np.isfinite(q_oxidized)): | ||
| fminus = -1.0 * (q_neutral - q_oxidized) | ||
| else: | ||
| fminus = np.full(len(atomnos), np.nan) | ||
|
|
||
| frad = 0.5 * (fplus + fminus) | ||
| entry["atom"]["fplus"] = fplus | ||
| entry["atom"]["fminus"] = fminus | ||
| entry["atom"]["frad"] = frad | ||
| entry["atom"]["charges_neutral"] = q_neutral | ||
|
|
||
| self.data_dict[key] = entry | ||
|
|
||
| return self.data_dict | ||
|
Comment on lines
+125
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map the relevant file first, then inspect the surrounding code paths.
printf '\n== moldscript/MLIP.py outline ==\n'
ast-grep outline moldscript/MLIP.py --view expanded || true
printf '\n== moldscript/MLIP.py around the assignment ==\n'
sed -n '1,260p' moldscript/MLIP.py | cat -n
printf '\n== search for data_dict construction / reuse ==\n'
rg -n "data_dict|mlip\(" moldscript.py moldscript -g '!**/__pycache__/**' || true
printf '\n== inspect charges.py pattern mentioned in the comment ==\n'
if [ -f moldscript/charges.py ]; then
sed -n '1,240p' moldscript/charges.py | cat -n
fiRepository: patonlab/molDscript Length of output: 33898 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== moldscript.py around the MLIP call ==\n'
sed -n '120,150p' moldscript/moldscript.py | cat -n
printf '\n== MLIP key normalization and base-state selection ==\n'
sed -n '260,340p' moldscript/MLIP.py | cat -n
printf '\n== utils.initiate_data_dict and record_cpu_time ==\n'
sed -n '130,210p' moldscript/utils.py | cat -n
sed -n '80,115p' moldscript/utils.py | cat -nRepository: patonlab/molDscript Length of output: 9986 Merge MLIP fields into the existing entry 🤖 Prompt for AI Agents |
||
|
|
||
| def _read_extxyz(self, file_path): | ||
| lines = file_path.read_text(encoding="utf-8").splitlines() | ||
| if len(lines) < 2: | ||
| raise ValueError(f"Invalid extxyz file: {file_path}") | ||
|
|
||
| natoms = int(lines[0].strip()) | ||
| header_line = lines[1].strip() | ||
| header = self._parse_header_line(header_line) | ||
|
|
||
| schema = self._parse_properties_schema(header.get("Properties", "species:S:1:pos:R:3")) | ||
| atom_lines = lines[2 : 2 + natoms] | ||
|
Comment on lines
+204
to
+214
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win No validation that the file actually contains
Consider asserting 🤖 Prompt for AI Agents |
||
|
|
||
| symbols = [] | ||
| coords = [] | ||
| atom_props = {name: [] for (name, ncols) in schema if name not in ("species", "pos") and ncols == 1} | ||
|
|
||
| for line in atom_lines: | ||
| toks = line.split() | ||
| idx = 0 | ||
| row = {} | ||
| for name, ncols in schema: | ||
| vals = toks[idx : idx + ncols] | ||
| idx += ncols | ||
| row[name] = vals if ncols > 1 else vals[0] | ||
|
|
||
| symbols.append(str(row["species"])) | ||
| coords.append([float(x) for x in row["pos"]]) | ||
|
|
||
| for prop_name in atom_props.keys(): | ||
| atom_props[prop_name].append(self._to_scalar(row[prop_name])) | ||
|
|
||
| for prop_name, values in atom_props.items(): | ||
| atom_props[prop_name] = np.array(values, dtype=float) | ||
|
|
||
| return { | ||
| "file_path": str(file_path), | ||
| "natoms": natoms, | ||
| "header": header, | ||
| "symbols": symbols, | ||
| "coords": coords, | ||
| "atom_props": atom_props, | ||
| } | ||
|
|
||
| def _parse_header_line(self, line): | ||
| out = {} | ||
| for token in shlex.split(line): | ||
| if "=" not in token: | ||
| continue | ||
| k, v = token.split("=", 1) | ||
| out[k] = v | ||
| return out | ||
|
|
||
| def _parse_properties_schema(self, properties): | ||
| # e.g. species:S:1:pos:R:3:q_mace_polar:R:1 | ||
| parts = properties.split(":") | ||
| schema = [] | ||
| i = 0 | ||
| while i + 2 < len(parts): | ||
| name = parts[i] | ||
| ncols = int(parts[i + 2]) | ||
| schema.append((name, ncols)) | ||
| i += 3 | ||
| return schema | ||
|
|
||
| def _canonical_key(self, header, file_path): | ||
| source = header.get("source_file", "") | ||
| if source: | ||
| return Path(source).stem | ||
|
Comment on lines
+269
to
+271
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When extxyz headers contain state-specific Useful? React with 👍 / 👎. |
||
|
|
||
| stem = file_path.stem | ||
| suffixes = [ | ||
| "_neutral_vertical", | ||
| "_anion_Nplus1_vertical", | ||
| "_cation_Nminus1_vertical", | ||
| "_neutral", | ||
| "_anion_Nplus1", | ||
| "_cation_Nminus1", | ||
| ] | ||
| for suffix in suffixes: | ||
| if stem.endswith(suffix): | ||
| return stem[: -len(suffix)] | ||
| return stem | ||
|
|
||
| def _pick_base_state(self, states): | ||
| if "neutral" in states: | ||
| return "neutral" | ||
| if "reduced" in states: | ||
| return "reduced" | ||
| return "oxidized" | ||
|
|
||
| def _state_energy_ev(self, record): | ||
| if record is None: | ||
| return np.nan | ||
| return self._to_scalar(record["header"].get("mace_polar_energy_eV", np.nan)) | ||
|
|
||
| def _state_charges(self, record, natoms): | ||
| if record is None: | ||
| return np.full(natoms, np.nan) | ||
| values = record["atom_props"].get(self.atom_charge_property) | ||
| if values is None: | ||
| return np.full(natoms, np.nan) | ||
| arr = np.array(values, dtype=float) | ||
| if len(arr) == natoms: | ||
| return arr | ||
| padded = np.full(natoms, np.nan) | ||
| ncopy = min(natoms, len(arr)) | ||
| padded[:ncopy] = arr[:ncopy] | ||
| return padded | ||
|
Comment on lines
+299
to
+311
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Silent NaN-padding on atom-count mismatch. If a state's charge array length differs from Consider logging a warning when 🤖 Prompt for AI Agents |
||
|
|
||
| def _dipole_vector(self, states): | ||
| for state in ["neutral", "reduced", "oxidized"]: | ||
| record = states.get(state) | ||
| if record is None: | ||
| continue | ||
| header = record["header"] | ||
| x = self._to_scalar(header.get("mace_polar_dipole_x", np.nan)) | ||
| y = self._to_scalar(header.get("mace_polar_dipole_y", np.nan)) | ||
| z = self._to_scalar(header.get("mace_polar_dipole_z", np.nan)) | ||
| vec = np.array([x, y, z], dtype=float) | ||
| if np.any(np.isfinite(vec)): | ||
| return vec | ||
| return np.array([np.nan, np.nan, np.nan], dtype=float) | ||
|
|
||
| def _bond_length_matrix(self, coords): | ||
| nat = len(coords) | ||
| out = np.zeros((nat, nat), dtype=float) | ||
| for i in range(nat): | ||
| delta = coords - coords[i] | ||
| out[i, :] = np.sqrt(np.sum(delta * delta, axis=1)) | ||
| return out | ||
|
|
||
| def _to_scalar(self, value): | ||
| try: | ||
| return float(value) | ||
| except (TypeError, ValueError): | ||
| return value | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When MLIP is combined with any earlier module in the same CLI run,
main()passes the accumulateddata_dictsintomlip, but this assignment replaces the whole molecule entry for matching keys. For extxyz files whosesource_filestem matches the Gaussian-derived key, descriptors already parsed from opt/SPC/NMR/NBO/charges are silently dropped from the final CSVs and only the MLIP fields remain; merge into the existing nestedmol/atom/bonddicts instead of overwriting the entry.Useful? React with 👍 / 👎.