@dataclass
class LMP:
comment: t.Optional[str]
headers: t.Dict[str, t.Any]
sections: t.Tuple[LMPSection, ...]
def get_cell(self) -> Cell:
dims = numpy.array([
self.headers.get(f"{c}lo {c}hi", (-0.5, 0.5))
for c in "xyz"
])
origin = dims[:, 0]
tilts = self.headers.get("xy xz yz", (0., 0., 0.))
ortho = numpy.diag(dims[:, 1] - dims[:, 0])
(ortho[0, 1], ortho[0, 2], ortho[1, 2]) = tilts
return Cell.from_ortho(LinearTransform3D(ortho).translate(origin))
def get_atoms(self, type_map: t.Optional[t.Dict[int, t.Union[str, int]]] = None) -> AtomCell:
if type_map is not None:
try:
type_map_df = polars.DataFrame({
'type': polars.Series(type_map.keys(), dtype=polars.Int32),
'elem': polars.Series(list(map(get_elem, type_map.values())), dtype=polars.UInt8),
'symbol': polars.Series([get_sym(v) if isinstance(v, int) else v for v in type_map.values()], dtype=polars.Utf8),
})
except ValueError as e:
raise ValueError("Invalid type map") from e
else:
type_map_df = None
cell = self.get_cell()
def _apply_type_labels(df: polars.DataFrame, section_name: str, labels: t.Optional[polars.DataFrame] = None) -> polars.DataFrame:
if labels is not None:
#df = df.with_columns(polars.col('type').replace(d, default=polars.col('type').cast(polars.Int32, strict=False), return_dtype=polars.Int32))
df = df.with_columns(polars.col('type').replace_strict(labels['symbol'], labels['type'], default=polars.col('type').cast(polars.Int32, strict=False), return_dtype=polars.Int32))
if df['type'].is_null().any():
raise ValueError(f"While parsing section {section_name}: Unknown atom label or invalid atom type")
try:
return df.with_columns(polars.col('type').cast(polars.Int32))
except polars.ComputeError:
raise ValueError(f"While parsing section {section_name}: Invalid atom type(s)")
atoms: t.Optional[polars.DataFrame] = None
labels: t.Optional[polars.DataFrame] = None
masses: t.Optional[polars.DataFrame] = None
velocities = None
for section in self.sections:
start_line = section.start_line + 1
if section.name == 'Atoms':
if section.style not in (None, 'atomic'):
# TODO support other styles
raise ValueError(f"Only 'atomic' atom_style is supported, instead got '{section.style}'")
atoms = parse_whitespace_separated(section.body, {
'i': polars.Int64, 'type': polars.Utf8,
'coords': polars.Array(polars.Float64, 3),
}, start_line=start_line)
atoms = _apply_type_labels(atoms, 'Atoms', labels)
elif section.name == 'Atom Type Labels':
labels = parse_whitespace_separated(section.body, {'type': polars.Int32, 'symbol': polars.Utf8}, start_line=start_line)
elif section.name == 'Masses':
masses = parse_whitespace_separated(section.body, {'type': polars.Utf8, 'mass': polars.Float64}, start_line=start_line)
masses = _apply_type_labels(masses, 'Masses', labels)
elif section.name == 'Velocities':
velocities = parse_whitespace_separated(section.body, {
'i': polars.Int64, 'velocity': polars.Array(polars.Float64, 3),
}, start_line=start_line)
# now all 'type's should be in Int32
if atoms is None:
if self.headers['atoms'] > 0:
raise ValueError("Missing required section 'Atoms'")
return AtomCell(Atoms.empty(), cell=cell, frame='local')
# next we need to assign element symbols
# first, if type_map is specified, use that:
#if type_map_elem is not None and type_map_sym is not None:
if type_map_df is not None:
try:
atoms = checked_left_join(atoms, type_map_df, on='type')
except CheckedJoinError as e:
raise ValueError(f"Missing type_map specification for atom type(s): {', '.join(map(repr, e.missing_keys))}")
elif labels is not None:
try:
labels = labels.with_columns(get_elem(labels['symbol']))
except ValueError as e:
raise ValueError("Failed to auto-detect elements from type labels. Please pass 'type_map' explicitly") from e
try:
atoms = checked_left_join(atoms, labels, 'type')
except CheckedJoinError as e:
raise ValueError(f"Missing labels for atom type(s): {', '.join(map(repr, e.missing_keys))}")
# otherwise we have no way
else:
raise ValueError("Failed to auto-detect elements from type labels. Please pass 'type_map' explicitly")
if velocities is not None:
# join velocities
try:
# TODO use join_asof here?
atoms = checked_left_join(atoms, velocities, 'i')
except CheckedJoinError as e:
raise ValueError(f"Missing velocities for {len(e.missing_keys)}/{len(atoms)} atoms")
if masses is not None:
# join masses
try:
atoms = checked_left_join(atoms, masses, 'type')
except CheckedJoinError as e:
raise ValueError(f"Missing masses for atom type(s): {', '.join(map(repr, e.missing_keys))}")
return AtomCell(atoms, cell=cell, frame='local')
@staticmethod
def from_atoms(atoms: HasAtoms) -> LMP:
if isinstance(atoms, HasAtomCell):
# we're basically converting everything to the ortho frame, but including the affine shift
# transform affine shift into ortho frame
origin = atoms.get_transform('ortho', 'local').to_linear().round_near_zero() \
.transform(atoms.get_cell().affine.translation())
# get the orthogonalization transform only, without affine
ortho = atoms.get_transform('ortho', 'cell_box').to_linear().round_near_zero().inner
# get atoms in ortho frame, and then add the affine shift
frame = atoms.get_atoms('ortho').transform_atoms(AffineTransform3D.translate(origin)) \
.round_near_zero().with_type()
else:
bbox = atoms.bbox_atoms()
ortho = numpy.diag(bbox.size)
origin = bbox.min
frame = atoms.get_atoms('local').with_type()
types = frame.unique(subset='type')
types = types.with_mass().sort('type')
now = localtime()
comment = f"# Generated by atomlib on {now.isoformat(' ', 'seconds')}"
headers = {}
sections = []
headers['atoms'] = len(frame)
headers['atom types'] = len(types)
for (s, low, diff) in zip(('x', 'y', 'z'), origin, ortho.diagonal()):
headers[f"{s}lo {s}hi"] = (low, low + diff)
headers['xy xz yz'] = (ortho[0, 1], ortho[0, 2], ortho[1, 2])
body = [
f" {ty:8} {sym:>4}\n"
for (ty, sym) in types.select('type', 'symbol').rows()
]
sections.append(LMPSection("Atom Type Labels", tuple(body)))
if 'mass' in types:
body = [
f" {ty:8} {mass:14.7f} # {sym}\n"
for (ty, sym, mass) in types.select(('type', 'symbol', 'mass')).rows()
]
sections.append(LMPSection("Masses", tuple(body)))
body = [
f" {i+1:8} {ty:4} {x:14.7f} {y:14.7f} {z:14.7f}\n"
for (i, (ty, (x, y, z))) in enumerate(frame.select(('type', 'coords')).rows())
]
sections.append(LMPSection("Atoms", tuple(body), 'atomic'))
if (velocities := frame.velocities()) is not None:
body = [
f" {i+1:8} {v_x:14.7f} {v_y:14.7f} {v_z:14.7f}\n"
for (i, (v_x, v_y, v_z)) in enumerate(velocities)
]
sections.append(LMPSection("Velocities", tuple(body)))
return LMP(comment, headers, tuple(sections))
@staticmethod
def from_file(file: FileOrPath) -> LMP:
with open_file(file, 'r') as f:
return LMPReader(f).parse()
def write(self, file: FileOrPath):
with open_file(file, 'w') as f:
print((self.comment or "") + '\n', file=f)
# print headers
for (name, val) in self.headers.items():
val = _HEADER_FMT.get(name, lambda s: f"{s:8}")(val)
print(f" {val} {name}", file=f)
# print sections
for section in self.sections:
line = section.name
if section.style is not None:
line += f' # {section.style}'
print(f"\n{line}\n", file=f)
f.writelines(section.body)