Skip to content

Core

The core module provides the fundamental data structures and building blocks for MolPy.

Atomistic

Atom

Bases: Entity

Atom entity (expects optional keys like {"type": "C", "xyz": [...]})

Atomistic

Atomistic(**props)

Bases: Struct, MembershipMixin, SpatialMixin, ConnectivityMixin

Source code in src/molpy/core/atomistic.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def __init__(self, **props) -> None:
    super().__init__(**props)
    # Call __post_init__ if it exists (for template pattern)
    if hasattr(self, "__post_init__"):
        # Get the method from the actual class, not from parent
        for klass in type(self).__mro__:
            if klass is Atomistic:
                break
            if "__post_init__" in klass.__dict__:
                klass.__dict__["__post_init__"](self, **props)
                break
    self.entities.register_type(Atom)
    self.links.register_type(Bond)
    self.links.register_type(Angle)
    self.links.register_type(Dihedral)

positions property

positions

Alias for xyz property.

xyz property

xyz

Get atomic positions as numpy array.

Returns:

Type Description

Nx3 array of atomic coordinates, or list of lists if numpy not available

add_angle

add_angle(angle)

Add an existing Angle object to the structure.

Source code in src/molpy/core/atomistic.py
337
338
339
340
def add_angle(self, angle: Angle, /) -> Angle:
    """Add an existing Angle object to the structure."""
    self.links.add(angle)
    return angle

add_angles

add_angles(angles)

Add multiple existing Angle objects to the structure.

Source code in src/molpy/core/atomistic.py
425
426
427
428
429
def add_angles(self, angles: list[Angle], /) -> list[Angle]:
    """Add multiple existing Angle objects to the structure."""
    for angle in angles:
        self.links.add(angle)
    return angles

add_atom

add_atom(atom)

Add an existing Atom object to the structure.

Source code in src/molpy/core/atomistic.py
327
328
329
330
def add_atom(self, atom: Atom, /) -> Atom:
    """Add an existing Atom object to the structure."""
    self.entities.add(atom)
    return atom

add_atoms

add_atoms(atoms)

Add multiple existing Atom objects to the structure.

Source code in src/molpy/core/atomistic.py
413
414
415
416
417
def add_atoms(self, atoms: list[Atom], /) -> list[Atom]:
    """Add multiple existing Atom objects to the structure."""
    for atom in atoms:
        self.entities.add(atom)
    return atoms

add_bond

add_bond(bond)

Add an existing Bond object to the structure.

Source code in src/molpy/core/atomistic.py
332
333
334
335
def add_bond(self, bond: Bond, /) -> Bond:
    """Add an existing Bond object to the structure."""
    self.links.add(bond)
    return bond

add_bonds

add_bonds(bonds)

Add multiple existing Bond objects to the structure.

Source code in src/molpy/core/atomistic.py
419
420
421
422
423
def add_bonds(self, bonds: list[Bond], /) -> list[Bond]:
    """Add multiple existing Bond objects to the structure."""
    for bond in bonds:
        self.links.add(bond)
    return bonds

add_dihedral

add_dihedral(dihedral)

Add an existing Dihedral object to the structure.

Source code in src/molpy/core/atomistic.py
342
343
344
345
def add_dihedral(self, dihedral: Dihedral, /) -> Dihedral:
    """Add an existing Dihedral object to the structure."""
    self.links.add(dihedral)
    return dihedral

add_dihedrals

add_dihedrals(dihedrals)

Add multiple existing Dihedral objects to the structure.

Source code in src/molpy/core/atomistic.py
431
432
433
434
435
def add_dihedrals(self, dihedrals: list[Dihedral], /) -> list[Dihedral]:
    """Add multiple existing Dihedral objects to the structure."""
    for dihedral in dihedrals:
        self.links.add(dihedral)
    return dihedrals

align

align(a, b, *, a_dir=None, b_dir=None, flip=False, entity_type=Atom)

Align entities. Returns self for chaining.

Source code in src/molpy/core/atomistic.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
def align(
    self,
    a: Entity,
    b: Entity,
    *,
    a_dir: list[float] | None = None,
    b_dir: list[float] | None = None,
    flip: bool = False,
    entity_type: type[Entity] = Atom,
) -> "Atomistic":
    """Align entities. Returns self for chaining."""
    super().align(
        a, b, a_dir=a_dir, b_dir=b_dir, flip=flip, entity_type=entity_type
    )
    return self

def_angle

def_angle(a, b, c, /, **attrs)

Create a new Angle between three atoms and add it to the structure.

Source code in src/molpy/core/atomistic.py
228
229
230
231
232
def def_angle(self, a: Atom, b: Atom, c: Atom, /, **attrs: Any) -> Angle:
    """Create a new Angle between three atoms and add it to the structure."""
    angle = Angle(a, b, c, **attrs)
    self.links.add(angle)
    return angle

def_angles

def_angles(angles_data)

Create multiple Angles from a list of (itom, jtom, ktom) or (itom, jtom, ktom, attrs) tuples.

Source code in src/molpy/core/atomistic.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
def def_angles(
    self,
    angles_data: list[
        tuple[Atom, Atom, Atom] | tuple[Atom, Atom, Atom, dict[str, Any]]
    ],
    /,
) -> list[Angle]:
    """Create multiple Angles from a list of (itom, jtom, ktom) or (itom, jtom, ktom, attrs) tuples."""
    angles = []
    for angle_spec in angles_data:
        if len(angle_spec) == 3:
            a, b, c = angle_spec
            attrs = {}
        else:
            a, b, c, attrs = angle_spec
        angle = self.def_angle(a, b, c, **attrs)
        angles.append(angle)
    return angles

def_atom

def_atom(**attrs)

Create a new Atom and add it to the structure.

If 'xyz' is provided, it will be converted to separate x, y, z fields.

Source code in src/molpy/core/atomistic.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def def_atom(self, **attrs: Any) -> Atom:
    """Create a new Atom and add it to the structure.

    If 'xyz' is provided, it will be converted to separate x, y, z fields.
    """
    # Convert xyz to x, y, z if provided
    if "xyz" in attrs:
        xyz = attrs.pop("xyz")
        attrs["x"] = float(xyz[0])
        attrs["y"] = float(xyz[1])
        attrs["z"] = float(xyz[2])

    atom = Atom(**attrs)
    self.entities.add(atom)
    return atom

def_atoms

def_atoms(atoms_data)

Create multiple Atoms from a list of attribute dictionaries.

Source code in src/molpy/core/atomistic.py
349
350
351
352
353
354
355
def def_atoms(self, atoms_data: list[dict[str, Any]], /) -> list[Atom]:
    """Create multiple Atoms from a list of attribute dictionaries."""
    atoms = []
    for attrs in atoms_data:
        atom = self.def_atom(**attrs)
        atoms.append(atom)
    return atoms

def_bond

def_bond(a, b, /, **attrs)

Create a new Bond between two atoms and add it to the structure.

Source code in src/molpy/core/atomistic.py
222
223
224
225
226
def def_bond(self, a: Atom, b: Atom, /, **attrs: Any) -> Bond:
    """Create a new Bond between two atoms and add it to the structure."""
    bond = Bond(a, b, **attrs)
    self.links.add(bond)
    return bond

def_bonds

def_bonds(bonds_data)

Create multiple Bonds from a list of (itom, jtom) or (itom, jtom, attrs) tuples.

Source code in src/molpy/core/atomistic.py
357
358
359
360
361
362
363
364
365
366
367
368
369
370
def def_bonds(
    self, bonds_data: list[tuple[Atom, Atom] | tuple[Atom, Atom, dict[str, Any]]], /
) -> list[Bond]:
    """Create multiple Bonds from a list of (itom, jtom) or (itom, jtom, attrs) tuples."""
    bonds = []
    for bond_spec in bonds_data:
        if len(bond_spec) == 2:
            a, b = bond_spec
            attrs = {}
        else:
            a, b, attrs = bond_spec
        bond = self.def_bond(a, b, **attrs)
        bonds.append(bond)
    return bonds

def_dihedral

def_dihedral(a, b, c, d, /, **attrs)

Create a new Dihedral between four atoms and add it to the structure.

Source code in src/molpy/core/atomistic.py
234
235
236
237
238
239
240
def def_dihedral(
    self, a: Atom, b: Atom, c: Atom, d: Atom, /, **attrs: Any
) -> Dihedral:
    """Create a new Dihedral between four atoms and add it to the structure."""
    dihedral = Dihedral(a, b, c, d, **attrs)
    self.links.add(dihedral)
    return dihedral

def_dihedrals

def_dihedrals(dihedrals_data)

Create multiple Dihedrals from a list of (itom, jtom, ktom, ltom) or (itom, jtom, ktom, ltom, attrs) tuples.

Source code in src/molpy/core/atomistic.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
def def_dihedrals(
    self,
    dihedrals_data: list[
        tuple[Atom, Atom, Atom, Atom]
        | tuple[Atom, Atom, Atom, Atom, dict[str, Any]]
    ],
    /,
) -> list[Dihedral]:
    """Create multiple Dihedrals from a list of (itom, jtom, ktom, ltom) or (itom, jtom, ktom, ltom, attrs) tuples."""
    dihedrals = []
    for dihe_spec in dihedrals_data:
        if len(dihe_spec) == 4:
            a, b, c, d = dihe_spec
            attrs = {}
        else:
            a, b, c, d, attrs = dihe_spec
        dihedral = self.def_dihedral(a, b, c, d, **attrs)
        dihedrals.append(dihedral)
    return dihedrals

extract_subgraph

extract_subgraph(center_entities, radius, entity_type=Atom, link_type=Bond)

Extract subgraph preserving all topology (bonds, angles, dihedrals).

Overrides ConnectivityMixin.extract_subgraph to ensure all topology types (bonds, angles, dihedrals) are preserved in the extracted subgraph.

Parameters:

Name Type Description Default
center_entities Iterable[Atom]

Center atoms for extraction

required
radius int

Topological radius

required
entity_type type[Atom]

Entity type (should be Atom)

Atom
link_type type[Link]

Link type for topology calculation (should be Bond)

Bond

Returns:

Type Description
tuple[Atomistic, list[Atom]]

Tuple of (subgraph Atomistic, edge atoms)

Source code in src/molpy/core/atomistic.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def extract_subgraph(
    self,
    center_entities: Iterable[Atom],
    radius: int,
    entity_type: type[Atom] = Atom,
    link_type: type[Link] = Bond,
) -> tuple["Atomistic", list[Atom]]:
    """Extract subgraph preserving all topology (bonds, angles, dihedrals).

    Overrides ConnectivityMixin.extract_subgraph to ensure all topology
    types (bonds, angles, dihedrals) are preserved in the extracted subgraph.

    Args:
        center_entities: Center atoms for extraction
        radius: Topological radius
        entity_type: Entity type (should be Atom)
        link_type: Link type for topology calculation (should be Bond)

    Returns:
        Tuple of (subgraph Atomistic, edge atoms)
    """
    from copy import deepcopy

    # Call parent method to extract subgraph with bonds
    subgraph, edge_entities = super().extract_subgraph(
        center_entities=center_entities,
        radius=radius,
        entity_type=entity_type,
        link_type=link_type,
    )

    # Build mapping from original atoms to subgraph atoms (by react_id or id)
    original_to_subgraph = {}
    subgraph_atoms_set = set(subgraph.atoms)

    for subgraph_atom in subgraph_atoms_set:
        # Try to match by react_id first, then by id
        subgraph_rid = subgraph_atom.get("react_id")
        subgraph_id = subgraph_atom.get("id")

        for orig_atom in self.atoms:
            if subgraph_rid and orig_atom.get("react_id") == subgraph_rid:
                original_to_subgraph[orig_atom] = subgraph_atom
                break
            elif subgraph_id and orig_atom.get("id") == subgraph_id:
                original_to_subgraph[orig_atom] = subgraph_atom
                break

    # Copy angles from original to subgraph
    for angle in self.angles:
        endpoints = angle.endpoints
        if all(ep in original_to_subgraph for ep in endpoints):
            subgraph_eps = [original_to_subgraph[ep] for ep in endpoints]
            # Check if angle already exists
            exists = any(
                set(a.endpoints) == set(subgraph_eps) for a in subgraph.angles
            )
            if not exists:
                attrs = deepcopy(getattr(angle, "data", {}))
                subgraph.def_angle(*subgraph_eps, **attrs)

    # Copy dihedrals from original to subgraph
    for dihedral in self.dihedrals:
        endpoints = dihedral.endpoints
        if all(ep in original_to_subgraph for ep in endpoints):
            subgraph_eps = [original_to_subgraph[ep] for ep in endpoints]
            # Check if dihedral already exists
            exists = any(
                set(d.endpoints) == set(subgraph_eps) for d in subgraph.dihedrals
            )
            if not exists:
                attrs = deepcopy(getattr(dihedral, "data", {}))
                subgraph.def_dihedral(*subgraph_eps, **attrs)

    # Convert edge_entities to list of Atoms
    edge_atoms = [e for e in edge_entities if isinstance(e, Atom)]

    return subgraph, edge_atoms
    """Create a new Dihedral between four atoms and add it to the structure."""
    dihedral = Dihedral(a, b, c, d, **attrs)
    self.links.add(dihedral)
    return dihedral

get_topo

get_topo(entity_type=Atom, link_type=Bond, gen_angle=False, gen_dihe=False, clear_existing=False)

Generate topology (angles and dihedrals) from bonds.

Parameters:

Name Type Description Default
entity_type type[Entity]

Entity type to include in topology (default: Atom)

Atom
link_type type[Link]

Link type to use for connections (default: Bond)

Bond
gen_angle bool

Whether to generate angles

False
gen_dihe bool

Whether to generate dihedrals

False
clear_existing bool

If True, clear existing angles/dihedrals before generating new ones. If False, only add angles/dihedrals that don't already exist.

False

Returns:

Type Description

Topology object

Source code in src/molpy/core/atomistic.py
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
def get_topo(
    self,
    entity_type: type[Entity] = Atom,
    link_type: type[Link] = Bond,
    gen_angle: bool = False,
    gen_dihe: bool = False,
    clear_existing: bool = False,
):
    """Generate topology (angles and dihedrals) from bonds.

    Args:
        entity_type: Entity type to include in topology (default: Atom)
        link_type: Link type to use for connections (default: Bond)
        gen_angle: Whether to generate angles
        gen_dihe: Whether to generate dihedrals
        clear_existing: If True, clear existing angles/dihedrals before generating new ones.
                      If False, only add angles/dihedrals that don't already exist.

    Returns:
        Topology object
    """
    # Use the generic ConnectivityMixin.get_topo method
    topo = super().get_topo(entity_type=entity_type, link_type=link_type)

    # Get the entity mapping from Topology
    atoms = topo.idx_to_entity

    # gen_angle and gen_dihe only work with Atom entities
    if gen_angle and entity_type is not Atom:
        raise ValueError("gen_angle=True requires entity_type=Atom")
    if gen_dihe and entity_type is not Atom:
        raise ValueError("gen_dihe=True requires entity_type=Atom")

    if gen_angle:
        if clear_existing:
            # Remove all existing angles
            existing_angles = list(self.links.bucket(Angle))
            if existing_angles:
                self.links.remove(*existing_angles)

        # Build set of existing angle endpoints for deduplication
        existing_angle_endpoints: set[tuple[Atom, Atom, Atom]] = set()
        if not clear_existing:
            for angle in self.links.bucket(Angle):
                existing_angle_endpoints.add((angle.itom, angle.jtom, angle.ktom))

        # Add new angles, avoiding duplicates
        for angle in topo.angles:
            angle_indices = angle.tolist()
            itom = atoms[angle_indices[0]]
            jtom = atoms[angle_indices[1]]
            ktom = atoms[angle_indices[2]]

            # Check if this angle already exists
            if (itom, jtom, ktom) not in existing_angle_endpoints:
                new_angle = Angle(itom, jtom, ktom)
                self.links.add(new_angle)
                existing_angle_endpoints.add((itom, jtom, ktom))

    if gen_dihe:
        if clear_existing:
            # Remove all existing dihedrals
            existing_dihedrals = list(self.links.bucket(Dihedral))
            if existing_dihedrals:
                self.links.remove(*existing_dihedrals)

        # Build set of existing dihedral endpoints for deduplication
        existing_dihedral_endpoints: set[tuple[Atom, Atom, Atom, Atom]] = set()
        if not clear_existing:
            for dihedral in self.links.bucket(Dihedral):
                existing_dihedral_endpoints.add(
                    (dihedral.itom, dihedral.jtom, dihedral.ktom, dihedral.ltom)
                )

        # Add new dihedrals, avoiding duplicates
        for dihe in topo.dihedrals:
            dihe_indices = dihe.tolist()
            itom = atoms[dihe_indices[0]]
            jtom = atoms[dihe_indices[1]]
            ktom = atoms[dihe_indices[2]]
            ltom = atoms[dihe_indices[3]]

            # Check if this dihedral already exists
            if (itom, jtom, ktom, ltom) not in existing_dihedral_endpoints:
                new_dihedral = Dihedral(itom, jtom, ktom, ltom)
                self.links.add(new_dihedral)
                existing_dihedral_endpoints.add((itom, jtom, ktom, ltom))

    return topo

move

move(delta, *, entity_type=Atom)

Move all entities by delta. Returns self for chaining.

Source code in src/molpy/core/atomistic.py
439
440
441
442
443
444
def move(
    self, delta: list[float], *, entity_type: type[Entity] = Atom
) -> "Atomistic":
    """Move all entities by delta. Returns self for chaining."""
    super().move(delta, entity_type=entity_type)
    return self

replicate

replicate(n, transform=None)

Create n copies and merge them into a new system.

Parameters:

Name Type Description Default
n int

Number of copies to create

required
transform

Optional callable(copy, index) -> None to transform each copy

None
Example

Create 10 waters in a line

waters = Water().replicate(10, lambda mol, i: mol.move([i*5, 0, 0]))

Create 3x3 grid

grid = Methane().replicate(9, lambda mol, i: mol.move([i%35, i//35, 0]))

Source code in src/molpy/core/atomistic.py
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def replicate(self, n: int, transform=None) -> "Atomistic":
    """
    Create n copies and merge them into a new system.

    Args:
        n: Number of copies to create
        transform: Optional callable(copy, index) -> None to transform each copy

    Example:
        # Create 10 waters in a line
        waters = Water().replicate(10, lambda mol, i: mol.move([i*5, 0, 0]))

        # Create 3x3 grid
        grid = Methane().replicate(9, lambda mol, i: mol.move([i%3*5, i//3*5, 0]))
    """
    result = type(self)()  # Empty system of same type

    for i in range(n):
        replica = self.copy()
        if transform is not None:
            transform(replica, i)
        result.merge(replica)

    return result

rotate

rotate(axis, angle, about=None, *, entity_type=Atom)

Rotate entities around axis. Returns self for chaining.

Source code in src/molpy/core/atomistic.py
446
447
448
449
450
451
452
453
454
455
456
def rotate(
    self,
    axis: list[float],
    angle: float,
    about: list[float] | None = None,
    *,
    entity_type: type[Entity] = Atom,
) -> "Atomistic":
    """Rotate entities around axis. Returns self for chaining."""
    super().rotate(axis, angle, about=about, entity_type=entity_type)
    return self

scale

scale(factor, about=None, *, entity_type=Atom)

Scale entities by factor. Returns self for chaining.

Source code in src/molpy/core/atomistic.py
458
459
460
461
462
463
464
465
466
467
def scale(
    self,
    factor: float,
    about: list[float] | None = None,
    *,
    entity_type: type[Entity] = Atom,
) -> "Atomistic":
    """Scale entities by factor. Returns self for chaining."""
    super().scale(factor, about=about, entity_type=entity_type)
    return self

to_frame

to_frame(atom_fields=None)

Convert to LAMMPS data Frame format.

Converts this Atomistic structure into a Frame suitable for writing as a LAMMPS data file.

Parameters:

Name Type Description Default
atom_fields list[str] | None

List of atom fields to extract. If None, extracts all fields.

None
bond_fields

List of bond fields to extract. If None, extracts all fields.

required
angle_fields

List of angle fields to extract. If None, extracts all fields.

required
dihedral_fields

List of dihedral fields to extract. If None, extracts all fields.

required

Returns:

Type Description
Frame

Frame with atoms, bonds, angles, and dihedrals

Example

butane = CH3() + CH2() + CH3()

Extract all fields

frame = butane.to_frame()

Extract specific fields only

frame = butane.to_frame( ... atom_fields=['xyz', 'charge', 'element', 'type'], ... bond_fields=['itom', 'jtom', 'type'], ... ) writer = LammpsDataWriter("system.data") writer.write(frame)

Source code in src/molpy/core/atomistic.py
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
def to_frame(self, atom_fields: list[str] | None = None) -> "Frame":
    """Convert to LAMMPS data Frame format.

    Converts this Atomistic structure into a Frame suitable for writing
    as a LAMMPS data file.

    Args:
        atom_fields: List of atom fields to extract. If None, extracts all fields.
        bond_fields: List of bond fields to extract. If None, extracts all fields.
        angle_fields: List of angle fields to extract. If None, extracts all fields.
        dihedral_fields: List of dihedral fields to extract. If None, extracts all fields.

    Returns:
        Frame with atoms, bonds, angles, and dihedrals

    Example:
        >>> butane = CH3() + CH2() + CH3()
        >>> # Extract all fields
        >>> frame = butane.to_frame()
        >>> # Extract specific fields only
        >>> frame = butane.to_frame(
        ...     atom_fields=['xyz', 'charge', 'element', 'type'],
        ...     bond_fields=['itom', 'jtom', 'type'],
        ... )
        >>> writer = LammpsDataWriter("system.data")
        >>> writer.write(frame)
    """
    import numpy as np

    from .frame import Block, Frame

    frame = Frame()

    # Get all topology data
    atoms_data = list(self.atoms)
    bonds_data = list(self.bonds)
    angles_data = list(self.angles)
    dihedrals_data = list(self.dihedrals)

    # Build atoms Block - convert array of struct to struct of array
    # Determine which keys to extract
    if atom_fields is None:
        # Collect all keys from all atoms
        all_keys = set()
        for atom in atoms_data:
            all_keys.update(atom.keys())
    else:
        all_keys = set(atom_fields)

    # Initialize dict for all keys
    atom_dict = {key: [] for key in all_keys}

    # Create atom ID to index mapping
    atom_id_to_index = {}

    # Convert: just iterate and append each key's value
    for atom in atoms_data:
        atom_id_to_index[id(atom)] = len(atom_id_to_index)

        for key in all_keys:
            atom_dict[key].append(atom.get(key, None))

    # Convert to numpy arrays
    atom_dict_np = {k: np.array(v) for k, v in atom_dict.items()}
    frame["atoms"] = Block.from_dict(atom_dict_np)

    # Build bonds Block - convert array of struct to struct of array
    if bonds_data:
        # Always include atom references
        bond_dict = defaultdict(list)

        # Collect all keys from all bonds first to ensure consistent fields
        all_bond_keys = set()
        for bond in bonds_data:
            all_bond_keys.update(bond.keys())

        for bond_idx, bond in enumerate(bonds_data):
            # Atom references from properties
            # Validate that atoms exist in atoms_data
            if id(bond.itom) not in atom_id_to_index:
                raise ValueError(
                    f"Bond {bond_idx + 1}: atomi (id={id(bond.itom)}) is not in atoms list. "
                    f"This bond references an atom that was removed or is invalid."
                )
            if id(bond.jtom) not in atom_id_to_index:
                raise ValueError(
                    f"Bond {bond_idx + 1}: atomj (id={id(bond.jtom)}) is not in atoms list. "
                    f"This bond references an atom that was removed or is invalid."
                )
            bond_dict["atomi"].append(atom_id_to_index[id(bond.itom)])
            bond_dict["atomj"].append(atom_id_to_index[id(bond.jtom)])
            # Data fields - iterate over all keys to ensure consistent length
            for key in all_bond_keys:
                if key not in [
                    "atomi",
                    "atomj",
                ]:  # Skip atom indices, already added
                    value = bond.get(key, None)
                    bond_dict[key].append(value)

        # Ensure a 'type' column exists for compatibility with writers
        # If missing, raise error instead of using default
        n_bonds = len(bonds_data)
        if "type" not in bond_dict:
            raise ValueError(
                f"Bonds are missing 'type' field. All {n_bonds} bonds must have a 'type' attribute. "
                f"This may indicate that ring closure bonds were created without proper typing."
            )
        elif len(bond_dict["type"]) != n_bonds:
            # Some bonds are missing 'type' field
            missing_count = n_bonds - len(bond_dict["type"])
            raise ValueError(
                f"Bonds 'type' field has {len(bond_dict['type'])} values, but expected {n_bonds} "
                f"(based on atom index fields). {missing_count} bond(s) are missing 'type' field. "
                f"This may indicate that ring closure bonds were created without proper typing."
            )

        bond_dict_np = {k: np.array(v) for k, v in bond_dict.items()}
        frame["bonds"] = Block.from_dict(bond_dict_np)

    # Build angles Block - convert array of struct to struct of array
    if angles_data:
        angle_dict = defaultdict(list)

        # Collect all keys from all angles first to ensure consistent fields
        all_angle_keys = set()
        for angle in angles_data:
            all_angle_keys.update(angle.keys())

        for angle_idx, angle in enumerate(angles_data):
            # Atom references from properties
            # Validate that atoms exist in atoms_data
            if id(angle.itom) not in atom_id_to_index:
                raise ValueError(
                    f"Angle {angle_idx + 1}: atomi (id={id(angle.itom)}) is not in atoms list. "
                    f"This angle references an atom that was removed or is invalid."
                )
            if id(angle.jtom) not in atom_id_to_index:
                raise ValueError(
                    f"Angle {angle_idx + 1}: atomj (id={id(angle.jtom)}) is not in atoms list. "
                    f"This angle references an atom that was removed or is invalid."
                )
            if id(angle.ktom) not in atom_id_to_index:
                raise ValueError(
                    f"Angle {angle_idx + 1}: atomk (id={id(angle.ktom)}) is not in atoms list. "
                    f"This angle references an atom that was removed or is invalid."
                )
            angle_dict["atomi"].append(atom_id_to_index[id(angle.itom)])
            angle_dict["atomj"].append(atom_id_to_index[id(angle.jtom)])
            angle_dict["atomk"].append(atom_id_to_index[id(angle.ktom)])
            # Data fields - iterate over all keys to ensure consistent length
            for key in all_angle_keys:
                if key not in [
                    "atomi",
                    "atomj",
                    "atomk",
                ]:  # Skip atom indices, already added
                    value = angle.get(key, None)
                    angle_dict[key].append(value)

        # Ensure a 'type' column exists for compatibility with writers
        # If missing, raise error instead of using default
        n_angles = len(angles_data)
        if "type" not in angle_dict:
            raise ValueError(
                f"Angles are missing 'type' field. All {n_angles} angles must have a 'type' attribute."
            )
        elif len(angle_dict["type"]) != n_angles:
            missing_count = n_angles - len(angle_dict["type"])
            raise ValueError(
                f"Angles 'type' field has {len(angle_dict['type'])} values, but expected {n_angles} "
                f"(based on atom index fields). {missing_count} angle(s) are missing 'type' field."
            )

        angle_dict_np = {k: np.array(v) for k, v in angle_dict.items()}
        frame["angles"] = Block.from_dict(angle_dict_np)
    # Build dihedrals Block - convert array of struct to struct of array
    if dihedrals_data:
        dihedral_dict = defaultdict(list)

        # Collect all keys from all dihedrals first to ensure consistent fields
        all_dihedral_keys = set()
        for dihedral in dihedrals_data:
            all_dihedral_keys.update(dihedral.keys())

        for dihedral_idx, dihedral in enumerate(dihedrals_data):
            # Atom references from properties
            # Validate that atoms exist in atoms_data
            if id(dihedral.itom) not in atom_id_to_index:
                raise ValueError(
                    f"Dihedral {dihedral_idx + 1}: atomi (id={id(dihedral.itom)}) is not in atoms list. "
                    f"This dihedral references an atom that was removed or is invalid."
                )
            if id(dihedral.jtom) not in atom_id_to_index:
                raise ValueError(
                    f"Dihedral {dihedral_idx + 1}: atomj (id={id(dihedral.jtom)}) is not in atoms list. "
                    f"This dihedral references an atom that was removed or is invalid."
                )
            if id(dihedral.ktom) not in atom_id_to_index:
                raise ValueError(
                    f"Dihedral {dihedral_idx + 1}: atomk (id={id(dihedral.ktom)}) is not in atoms list. "
                    f"This dihedral references an atom that was removed or is invalid."
                )
            if id(dihedral.ltom) not in atom_id_to_index:
                raise ValueError(
                    f"Dihedral {dihedral_idx + 1}: atoml (id={id(dihedral.ltom)}) is not in atoms list. "
                    f"This dihedral references an atom that was removed or is invalid."
                )
            dihedral_dict["atomi"].append(atom_id_to_index[id(dihedral.itom)])
            dihedral_dict["atomj"].append(atom_id_to_index[id(dihedral.jtom)])
            dihedral_dict["atomk"].append(atom_id_to_index[id(dihedral.ktom)])
            dihedral_dict["atoml"].append(atom_id_to_index[id(dihedral.ltom)])
            # Data fields - iterate over all keys to ensure consistent length
            for key in all_dihedral_keys:
                if key not in [
                    "atomi",
                    "atomj",
                    "atomk",
                    "atoml",
                ]:  # Skip atom indices, already added
                    value = dihedral.get(key, None)
                    dihedral_dict[key].append(value)

        # Ensure a 'type' column exists for compatibility with writers
        # If missing, raise error instead of using default
        n_dihedrals = len(dihedrals_data)
        if "type" not in dihedral_dict:
            raise ValueError(
                f"Dihedrals are missing 'type' field. All {n_dihedrals} dihedrals must have a 'type' attribute."
            )
        elif len(dihedral_dict["type"]) != n_dihedrals:
            missing_count = n_dihedrals - len(dihedral_dict["type"])
            raise ValueError(
                f"Dihedrals 'type' field has {len(dihedral_dict['type'])} values, but expected {n_dihedrals} "
                f"(based on atom index fields). {missing_count} dihedral(s) are missing 'type' field."
            )

        dihedral_dict_np = {k: np.array(v) for k, v in dihedral_dict.items()}
        frame["dihedrals"] = Block.from_dict(dihedral_dict_np)

    return frame

Dihedral

Dihedral(a, b, c, d, /, **attrs)

Bases: Link

Dihedral (torsion) angle between four atoms

Source code in src/molpy/core/atomistic.py
73
74
75
76
77
78
def __init__(self, a: Atom, b: Atom, c: Atom, d: Atom, /, **attrs: Any):
    assert isinstance(a, Atom), f"atom a must be an Atom instance, got {type(a)}"
    assert isinstance(b, Atom), f"atom b must be an Atom instance, got {type(b)}"
    assert isinstance(c, Atom), f"atom c must be an Atom instance, got {type(c)}"
    assert isinstance(d, Atom), f"atom d must be an Atom instance, got {type(d)}"
    super().__init__([a, b, c, d], **attrs)

Box

Box

Box(matrix=None, pbc=np.ones(3, dtype=bool), origin=np.zeros(3))

Bases: PeriodicBoundary

Initialize a Box object.

Parameters:

Name Type Description Default
matrix ndarray | None

A 3x3 matrix representing the box dimensions. If None or all elements are zero, a zero matrix is used. If a 1D array of shape (3,) is provided, it is converted to a diagonal matrix. Defaults to None.

None
pbc ndarray

A 1D boolean array of shape (3,) indicating periodic boundary conditions along each axis. Defaults to an array of ones (True for all axes).

ones(3, dtype=bool)
origin ndarray

A 1D array of shape (3,) representing the origin of the box. Defaults to an array of zeros.

zeros(3)
Source code in src/molpy/core/box.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def __init__(
    self,
    matrix: ArrayLike | None = None,
    pbc: ArrayLike = np.ones(3, dtype=bool),
    origin: ArrayLike = np.zeros(3),
):
    """
    Initialize a Box object.

    Parameters:
        matrix (np.ndarray | None, optional): A 3x3 matrix representing the box dimensions.
            If None or all elements are zero, a zero matrix is used. If a 1D array of shape (3,)
            is provided, it is converted to a diagonal matrix. Defaults to None.
        pbc (np.ndarray, optional): A 1D boolean array of shape (3,) indicating periodic boundary
            conditions along each axis. Defaults to an array of ones (True for all axes).
        origin (np.ndarray, optional): A 1D array of shape (3,) representing the origin of the box.
            Defaults to an array of zeros.
    """
    super().__init__()
    if matrix is None or np.all(matrix == 0):
        _matrix = np.zeros((3, 3))
    else:
        _matrix = np.asarray(matrix)
        if _matrix.shape == (3,):
            _matrix = np.diag(_matrix)
        _matrix = Box.check_matrix(_matrix)
    self._matrix: np.ndarray = np.array(_matrix, dtype=float)
    self._pbc: np.ndarray = np.array(pbc, dtype=bool)
    self._origin: np.ndarray = np.array(origin, dtype=float)

bounds property

bounds

Get the bounds of the box.

Returns:

Type Description
ndarray

np.ndarray: A 2D array with shape (3, 2) representing the bounds of the box.

is_periodic property

is_periodic

Check if the box has periodic boundary conditions in all directions.

l property

l

Get the lengths of the box along each axis.

Returns:

Type Description
ndarray

np.ndarray: A 1D array containing the lengths of the box along

ndarray

the x, y, and z axes.

lx property writable

lx

Get the length of the box along the x-axis.

Returns:

Name Type Description
float float

The length of the box in the x-direction, derived from the

float

first element of the matrix representing the box dimensions.

ly property writable

ly

Get the length of the simulation box along the y-axis.

Returns:

Name Type Description
float float

The length of the box in the y-direction.

lz property writable

lz

Get the length of the simulation box along the z-axis.

Returns:

Name Type Description
float float

The length of the box in the z-direction.

matrix property

matrix

Get the matrix representation of the box.

Returns:

Type Description
ndarray

np.ndarray: A 3x3 matrix representing the box dimensions.

pbc property

pbc

Get the periodic boundary conditions of the box.

Returns:

Type Description
ndarray

np.ndarray: A boolean array indicating periodicity along each axis.

style property

style

Determine the style of the box based on its matrix.

Returns:

Name Type Description
Style Style

The style of the box (FREE, ORTHOGONAL, or TRICLINIC).

volume property

volume

Calculate the volume of the box.

Returns:

Name Type Description
float float

The volume of the box.

xhi property

xhi

Calculate the upper boundary of the box along the x-axis.

Returns:

Name Type Description
float float

The x-coordinate of the upper boundary of the box,

float

calculated as the difference between the first element of

float

the matrix's first row and the x-coordinate of the origin.

xlo property

xlo

Calculate the lower bound of the box along the x-axis.

Returns:

Name Type Description
float float

The x-coordinate of the lower bound, calculated as the

float

negative of the x-component of the origin.

xy property writable

xy

Retrieve the xy component of the matrix.

Returns:

Name Type Description
float float

The value at the (0, 1) position in the matrix.

yhi property

yhi

Calculate the upper boundary of the box along the y-axis.

Returns:

Name Type Description
float float

The upper boundary value of the box in the y-dimension,

float

calculated as the difference between the y-component of the

float

matrix and the y-component of the origin.

ylo property

ylo

Get the lower boundary of the box along the y-axis.

Returns:

Name Type Description
float float

The negative value of the y-coordinate of the origin.

zhi property

zhi

Calculate the z-component of the box's upper boundary.

Returns:

Name Type Description
float float

The z-coordinate of the upper boundary, calculated as the

float

difference between the z-component of the matrix and the z-component

float

of the origin.

zlo property

zlo

Calculate the lower boundary of the box along the z-axis.

Returns:

Name Type Description
float float

The z-coordinate of the lower boundary, calculated as the

float

negative value of the third component of the origin vector.

calc_lengths_angles_from_matrix staticmethod

calc_lengths_angles_from_matrix(matrix)

Calculate the lengths of the box edges and angles from its matrix.

Parameters:

Name Type Description Default
matrix ndarray

A 3x3 matrix representing the box.

required

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: lengths and angles

Source code in src/molpy/core/box.py
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
@staticmethod
def calc_lengths_angles_from_matrix(
    matrix: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """
    Calculate the lengths of the box edges and angles from its matrix.

    Args:
        matrix (np.ndarray): A 3x3 matrix representing the box.

    Returns:
        tuple[np.ndarray, np.ndarray]: lengths and angles
    """

    lx = matrix[0, 0]
    ly = matrix[1, 1]
    lz = matrix[2, 2]
    xy = matrix[0, 1]
    xz = matrix[0, 2]
    yz = matrix[1, 2]

    a = lx
    b = (ly**2 + xy**2) ** 0.5
    c = (lz**2 + xz**2 + yz**2) ** 0.5
    cos_a = (xy * xz + ly * yz) / (b * c)
    cos_b = xz / c
    cos_c = xy / b
    return np.array([a, b, c]), np.rad2deg(np.arccos([cos_a, cos_b, cos_c]))

calc_matrix_from_lengths_angles staticmethod

calc_matrix_from_lengths_angles(abc, angles)

Compute restricted triclinic box matrix from lengths and angles.

Parameters:

Name Type Description Default
abc ndarray

[a, b, c] lattice vector lengths

required
angles ndarray

[alpha, beta, gamma] in degrees (angles between (b,c), (a,c), (a,b))

required

Returns:

Type Description
ndarray

np.ndarray: 3x3 box matrix with lattice vectors as columns: [a | b | c]

Source code in src/molpy/core/box.py
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
@staticmethod
def calc_matrix_from_lengths_angles(
    abc: ArrayLike, angles: ArrayLike
) -> np.ndarray:
    """
    Compute restricted triclinic box matrix from lengths and angles.

    Args:
        abc (np.ndarray): [a, b, c] lattice vector lengths
        angles (np.ndarray): [alpha, beta, gamma] in degrees (angles between (b,c), (a,c), (a,b))

    Returns:
        np.ndarray: 3x3 box matrix with lattice vectors as columns: [a | b | c]
    """
    a, b, c = abc
    angles = alpha, beta, gamma = np.deg2rad(angles)
    cos_a, cos_b, cos_c = np.cos(angles)

    # Optional: volume sanity check
    cos_check = cos_a**2 + cos_b**2 + cos_c**2 - 2 * cos_a * cos_b * cos_c
    if cos_check >= 1.0:
        raise ValueError(
            f"Invalid box: angles produce non-physical volume. abc={abc}, angles={angles}"
        )

    if not (0 < alpha < np.pi):
        raise ValueError("alpha must be in (0, 180)")
    if not (0 < beta < np.pi):
        raise ValueError("beta must be in (0, 180)")
    if not (0 < gamma < np.pi):
        raise ValueError("gamma must be in (0, 180)")

    # Construct box
    lx = a
    xy = b * cos_c
    xz = c * cos_b
    ly = np.sqrt(b**2 - xy**2)
    yz = (b * c * cos_a - xy * xz) / ly
    tmp = c**2 - xz**2 - yz**2
    lz = np.sqrt(tmp)

    return np.array([[lx, xy, xz], [0.0, ly, yz], [0.0, 0.0, lz]])

calc_matrix_from_size_tilts staticmethod

calc_matrix_from_size_tilts(sizes, tilts)

Get restricted triclinic box matrix from sizes and tilts

Parameters:

Name Type Description Default
sizes ndarray

sizes of box edges

required
tilts ndarray

tilts between box edges

required

Returns:

Type Description
ndarray

np.ndarray: restricted triclinic box matrix

Source code in src/molpy/core/box.py
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
@staticmethod
def calc_matrix_from_size_tilts(sizes, tilts) -> np.ndarray:
    """
    Get restricted triclinic box matrix from sizes and tilts

    Args:
        sizes (np.ndarray): sizes of box edges
        tilts (np.ndarray): tilts between box edges

    Returns:
        np.ndarray: restricted triclinic box matrix
    """
    lx, ly, lz = sizes
    xy, xz, yz = tilts
    return np.array([[lx, xy, xz], [0, ly, yz], [0, 0, lz]])

calc_style_from_matrix staticmethod

calc_style_from_matrix(matrix)

Determine the style of the box based on its matrix.

Parameters:

Name Type Description Default
matrix ndarray

A 3x3 matrix representing the box.

required

Returns:

Name Type Description
Style Style

The style of the box (FREE, ORTHOGONAL, or TRICLINIC).

Raises:

Type Description
ValueError

If the matrix does not correspond to a valid style.

Source code in src/molpy/core/box.py
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
@staticmethod
def calc_style_from_matrix(matrix: np.ndarray) -> Style:
    """
    Determine the style of the box based on its matrix.

    Args:
        matrix (np.ndarray): A 3x3 matrix representing the box.

    Returns:
        Style: The style of the box (FREE, ORTHOGONAL, or TRICLINIC).

    Raises:
        ValueError: If the matrix does not correspond to a valid style.
    """
    if np.allclose(matrix, np.zeros(3)):
        return Box.Style.FREE
    elif np.allclose(matrix, np.diag(np.diagonal(matrix))):
        return Box.Style.ORTHOGONAL
    elif (matrix[np.tril_indices(3, -1)] == 0).all() and (
        matrix[np.triu_indices(3, 1)] != 0
    ).any():
        return Box.Style.TRICLINIC
    else:
        raise ValueError("Invalid box matrix")

check_matrix staticmethod

check_matrix(matrix)

Validate the box matrix.

Parameters:

Name Type Description Default
matrix ndarray

A 3x3 matrix to validate.

required

Returns:

Type Description
ndarray

np.ndarray: The validated matrix.

Raises:

Type Description
AssertionError

If the matrix is not valid.

Source code in src/molpy/core/box.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
@staticmethod
def check_matrix(matrix: np.ndarray) -> np.ndarray:
    """
    Validate the box matrix.

    Args:
        matrix (np.ndarray): A 3x3 matrix to validate.

    Returns:
        np.ndarray: The validated matrix.

    Raises:
        AssertionError: If the matrix is not valid.
    """
    assert isinstance(matrix, np.ndarray), "matrix must be np.ndarray"
    assert matrix.shape == (3, 3), "matrix must be (3, 3)"
    assert not np.isclose(np.linalg.det(matrix), 0), "matrix must be non-singular"
    return matrix

diff

diff(r1, r2)

Calculate the difference between two points considering periodic boundary conditions.

Parameters:

Name Type Description Default
r1 ndarray

The first point.

required
r2 ndarray

The second point.

required

Returns:

Type Description
ndarray

np.ndarray: The difference vector.

Source code in src/molpy/core/box.py
798
799
800
801
802
803
804
805
806
807
808
809
def diff(self, r1: np.ndarray, r2: np.ndarray) -> np.ndarray:
    """
    Calculate the difference between two points considering periodic boundary conditions.

    Args:
        r1 (np.ndarray): The first point.
        r2 (np.ndarray): The second point.

    Returns:
        np.ndarray: The difference vector.
    """
    return self.diff_dr(r1 - r2)

diff_all

diff_all(r1, r2)

Calculate the difference between all pairs of points in two sets.

Parameters:

Name Type Description Default
r1 ndarray

The first set of points.

required
r2 ndarray

The second set of points.

required

Returns:

Type Description
ndarray

np.ndarray: The difference vectors for all pairs.

Source code in src/molpy/core/box.py
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
def diff_all(self, r1: np.ndarray, r2: np.ndarray) -> np.ndarray:
    """
    Calculate the difference between all pairs of points in two sets.

    Args:
        r1 (np.ndarray): The first set of points.
        r2 (np.ndarray): The second set of points.

    Returns:
        np.ndarray: The difference vectors for all pairs.
    """
    all_dr = r1[:, np.newaxis, :] - r2[np.newaxis, :, :]
    original_shape = all_dr.shape
    all_dr = all_dr.reshape(-1, 3)
    all_dr = self.diff_dr(all_dr)
    all_dr = all_dr.reshape(original_shape)
    return all_dr

diff_dr

diff_dr(dr)

Calculate the difference vector considering periodic boundary conditions.

Parameters:

Name Type Description Default
dr ndarray

The difference vector.

required

Returns:

Type Description
ndarray

np.ndarray: The adjusted difference vector.

Source code in src/molpy/core/box.py
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
def diff_dr(self, dr: np.ndarray) -> np.ndarray:
    """
    Calculate the difference vector considering periodic boundary conditions.

    Args:
        dr (np.ndarray): The difference vector.

    Returns:
        np.ndarray: The adjusted difference vector.
    """
    match self.style:
        case Box.Style.FREE:
            return dr
        case Box.Style.ORTHOGONAL | Box.Style.TRICLINIC:
            fractional = self.make_fractional(dr)
            fractional -= np.round(fractional)
            return np.dot(self._matrix, fractional.T).T

dist

dist(r1, r2)

Calculate the distance between two points.

Parameters:

Name Type Description Default
r1 ndarray

The first point.

required
r2 ndarray

The second point.

required

Returns:

Type Description
ndarray

np.ndarray: The distance between the points.

Source code in src/molpy/core/box.py
829
830
831
832
833
834
835
836
837
838
839
840
841
def dist(self, r1: np.ndarray, r2: np.ndarray) -> np.ndarray:
    """
    Calculate the distance between two points.

    Args:
        r1 (np.ndarray): The first point.
        r2 (np.ndarray): The second point.

    Returns:
        np.ndarray: The distance between the points.
    """
    dr = self.diff(r1, r2)
    return np.linalg.norm(dr, axis=1)

dist_all

dist_all(r1, r2)

Calculate the distances between all pairs of points in two sets.

Parameters:

Name Type Description Default
r1 ndarray

The first set of points.

required
r2 ndarray

The second set of points.

required

Returns:

Type Description
ndarray

np.ndarray: The distances for all pairs.

Source code in src/molpy/core/box.py
843
844
845
846
847
848
849
850
851
852
853
854
855
def dist_all(self, r1: np.ndarray, r2: np.ndarray) -> np.ndarray:
    """
    Calculate the distances between all pairs of points in two sets.

    Args:
        r1 (np.ndarray): The first set of points.
        r2 (np.ndarray): The second set of points.

    Returns:
        np.ndarray: The distances for all pairs.
    """
    dr = self.diff_all(r1, r2)
    return np.linalg.norm(dr, axis=-1)

from_box classmethod

from_box(box)

Create a new box from an existing box.

Parameters:

Name Type Description Default
box Box

The existing box.

required

Returns:

Name Type Description
Box Box

A new box with the same properties as the existing box.

Source code in src/molpy/core/box.py
121
122
123
124
125
126
127
128
129
130
131
132
@classmethod
def from_box(cls, box: "Box") -> "Box":
    """
    Create a new box from an existing box.

    Args:
        box (Box): The existing box.

    Returns:
        Box: A new box with the same properties as the existing box.
    """
    return cls(box.matrix.copy(), box.pbc.copy(), box.origin.copy())

from_lengths_angles classmethod

from_lengths_angles(lengths, angles)

Get box matrix from lengths and angles

Parameters:

Name Type Description Default
lengths ndarray

lengths of box edges

required
angles ndarray

angles between box edges in degree

required

Returns:

Type Description

np.ndarray: box matrix

Source code in src/molpy/core/box.py
639
640
641
642
643
644
645
646
647
648
649
650
651
@classmethod
def from_lengths_angles(cls, lengths: ArrayLike, angles: ArrayLike):
    """
    Get box matrix from lengths and angles

    Args:
        lengths (np.ndarray): lengths of box edges
        angles (np.ndarray): angles between box edges in degree

    Returns:
        np.ndarray: box matrix
    """
    return cls(cls.calc_matrix_from_lengths_angles(lengths, angles))

general2restrict staticmethod

general2restrict(matrix)

Convert general triclinc box matrix to restricted triclinic box matrix

Ref

https://docs.lammps.org/Howto_triclinic.html#transformation-from-general-to-restricted-triclinic-boxes

Parameters:

Name Type Description Default
matrix ndarray

(3, 3) general triclinc box matrix

required

Returns:

Type Description
ndarray

np.ndarray: (3, 3) restricted triclinc box matrix

Source code in src/molpy/core/box.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
@staticmethod
def general2restrict(matrix: np.ndarray) -> np.ndarray:
    """
    Convert general triclinc box matrix to restricted triclinic box matrix

    Ref:
        https://docs.lammps.org/Howto_triclinic.html#transformation-from-general-to-restricted-triclinic-boxes

    Args:
        matrix (np.ndarray): (3, 3) general triclinc box matrix

    Returns:
        np.ndarray: (3, 3) restricted triclinc box matrix
    """
    A = matrix[:, 0]
    B = matrix[:, 1]
    C = matrix[:, 2]
    ax = np.linalg.norm(A)
    uA = A / ax
    bx = np.dot(B, uA)
    by = np.linalg.norm(np.cross(uA, B))
    cx = np.dot(C, uA)
    AxB = np.cross(A, B)
    uAxB = AxB / np.linalg.norm(AxB)
    cy = np.dot(C, np.cross(uAxB, uA))
    cz = np.dot(C, uAxB)
    # validation code
    # import numpy.testing as npt
    # gamma = np.arccos(np.dot(A, C) / np.linalg.norm(A) / np.linalg.norm(C))
    # beta = np.arccos(np.dot(A, B) / np.linalg.norm(A) / np.linalg.norm(B))
    # npt.assert_allclose(
    #     bx,
    #     np.linalg.norm(B) * np.cos(gamma),
    #     err_msg=f"{bx} != {np.linalg.norm(B) * np.cos(gamma)}",
    # )
    # npt.assert_allclose(
    #     by,
    #     np.linalg.norm(B) * np.sin(gamma),
    #     err_msg=f"{by} != {np.linalg.norm(B) * np.sin(gamma)}",
    # )
    # npt.assert_allclose(
    #     cx,
    #     np.linalg.norm(C) * np.cos(beta),
    #     err_msg=f"{cx} != {np.linalg.norm(C) * np.cos(beta)}",
    # )
    # npt.assert_allclose(
    #     cy,
    #     (np.dot(B, C) - bx * cx) / by,
    #     err_msg=f"{cy} != {(np.dot(B, C) - bx * cx) / by}",
    # )
    # npt.assert_allclose(
    #     cz,
    #     np.sqrt(np.linalg.norm(C) ** 2 - cx**2 - cy**2),
    #     err_msg=f"{cz} != {np.sqrt(np.linalg.norm(C) ** 2 - cx ** 2 - cy ** 2)}",
    # )
    # TODO: extract origin and direction
    return np.array([[ax, bx, cx], [0, by, cy], [0, 0, cz]])

get_images

get_images(xyz)

Get the image flags of particles, accounting for box origin and triclinic shape.

Source code in src/molpy/core/box.py
763
764
765
766
767
768
769
def get_images(self, xyz: np.ndarray) -> np.ndarray:
    """
    Get the image flags of particles, accounting for box origin and triclinic shape.
    """
    fractional = self.make_fractional(xyz)
    # Add small epsilon to avoid numerical floor error at box edges
    return np.floor(fractional + 1e-8).astype(int)

get_inv

get_inv()

Get the inverse of the box matrix.

Returns:

Type Description
ndarray

np.ndarray: The inverse of the box matrix.

Source code in src/molpy/core/box.py
771
772
773
774
775
776
777
778
def get_inv(self) -> np.ndarray:
    """
    Get the inverse of the box matrix.

    Returns:
        np.ndarray: The inverse of the box matrix.
    """
    return np.linalg.inv(self._matrix)

isin

isin(xyz)

Check if point(s) xyz are inside the box. Args: xyz (np.ndarray): shape (..., 3) Returns: np.ndarray: boolean array, True if inside

Source code in src/molpy/core/box.py
881
882
883
884
885
886
887
888
889
890
891
def isin(self, xyz: np.ndarray):
    """
    Check if point(s) xyz are inside the box.
    Args:
        xyz (np.ndarray): shape (..., 3)
    Returns:
        np.ndarray: boolean array, True if inside
    """
    xyz = np.asarray(xyz)
    fractional = self.make_fractional(xyz)
    return np.all((fractional >= 0) & (fractional < 1), axis=-1)

make_absolute

make_absolute(xyz)

Convert fractional coordinates to absolute coordinates.

Parameters:

Name Type Description Default
xyz ndarray

The fractional coordinates.

required

Returns:

Type Description
ndarray

np.ndarray: The absolute coordinates.

Source code in src/molpy/core/box.py
869
870
871
872
873
874
875
876
877
878
879
def make_absolute(self, xyz: np.ndarray) -> np.ndarray:
    """
    Convert fractional coordinates to absolute coordinates.

    Args:
        xyz (np.ndarray): The fractional coordinates.

    Returns:
        np.ndarray: The absolute coordinates.
    """
    return xyz @ self._matrix.T + self._origin

make_fractional

make_fractional(xyz)

Convert absolute coordinates to fractional coordinates.

Parameters:

Name Type Description Default
xyz ndarray

The absolute coordinates.

required

Returns:

Type Description
ndarray

np.ndarray: The fractional coordinates.

Source code in src/molpy/core/box.py
857
858
859
860
861
862
863
864
865
866
867
def make_fractional(self, xyz: np.ndarray) -> np.ndarray:
    """
    Convert absolute coordinates to fractional coordinates.

    Args:
        xyz (np.ndarray): The absolute coordinates.

    Returns:
        np.ndarray: The fractional coordinates.
    """
    return (xyz - self._origin) @ self.get_inv().T

merge

merge(other)

Merge two boxes to find their common space.

Parameters:

Name Type Description Default
other Box

The other box to merge with.

required

Returns:

Name Type Description
Box Box

A new box representing the common space.

Source code in src/molpy/core/box.py
893
894
895
896
897
898
899
900
901
902
903
def merge(self, other: "Box") -> "Box":
    """
    Merge two boxes to find their common space.

    Args:
        other (Box): The other box to merge with.

    Returns:
        Box: A new box representing the common space.
    """
    return Box(matrix=other.matrix)

plot

plot()

Plot the box representation. This method is a placeholder and should be implemented to visualize the box in 3D space.

Source code in src/molpy/core/box.py
77
78
79
80
81
82
def plot(self):
    """
    Plot the box representation. This method is a placeholder and should be implemented
    to visualize the box in 3D space.
    """
    ...

to_dict

to_dict()

Convert the box to a dictionary representation.

Returns:

Name Type Description
dict dict

A dictionary containing the box properties.

Source code in src/molpy/core/box.py
920
921
922
923
924
925
926
927
928
929
930
931
def to_dict(self) -> dict:
    """
    Convert the box to a dictionary representation.

    Returns:
        dict: A dictionary containing the box properties.
    """
    return {
        "matrix": self._matrix.tolist(),
        "pbc": self._pbc.tolist(),
        "origin": self._origin.tolist(),
    }

to_lengths_angles

to_lengths_angles()

Get lengths and angles from box matrix

Returns:

Type Description
tuple[ndarray, ndarray]

tuple[np.ndarray, np.ndarray]: lengths and angles

Source code in src/molpy/core/box.py
653
654
655
656
657
658
659
660
def to_lengths_angles(self) -> tuple[np.ndarray, np.ndarray]:
    """
    Get lengths and angles from box matrix

    Returns:
        tuple[np.ndarray, np.ndarray]: lengths and angles
    """
    return self.calc_lengths_angles_from_matrix(self._matrix)

transform

transform(transformation_matrix)

Transform the box using a transformation matrix.

Parameters:

Name Type Description Default
transformation_matrix ndarray

3x3 transformation matrix.

required

Returns:

Type Description
Box

New Box with transformed dimensions.

Source code in src/molpy/core/box.py
905
906
907
908
909
910
911
912
913
914
915
916
917
918
def transform(self, transformation_matrix: np.ndarray) -> "Box":
    """Transform the box using a transformation matrix.

    Args:
        transformation_matrix: 3x3 transformation matrix.

    Returns:
        New Box with transformed dimensions.
    """
    # Transform the box matrix
    new_matrix = self._matrix @ transformation_matrix

    # Keep the same periodic boundary conditions and origin
    return Box(matrix=new_matrix, pbc=self._pbc.copy(), origin=self._origin.copy())

unwrap

unwrap(xyz, image)

Unwrap the coordinates of a particle based on its image.

Parameters:

Name Type Description Default
xyz ndarray

The coordinates of the particle.

required
image ndarray

The image of the particle.

required

Returns:

Type Description
ndarray

np.ndarray: The unwrapped coordinates.

Source code in src/molpy/core/box.py
750
751
752
753
754
755
756
757
758
759
760
761
def unwrap(self, xyz: np.ndarray, image: np.ndarray) -> np.ndarray:
    """
    Unwrap the coordinates of a particle based on its image.

    Args:
        xyz (np.ndarray): The coordinates of the particle.
        image (np.ndarray): The image of the particle.

    Returns:
        np.ndarray: The unwrapped coordinates.
    """
    return xyz + image @ self._matrix.T

wrap_free

wrap_free(xyz)

Wrap coordinates for a free box style.

Parameters:

Name Type Description Default
xyz ndarray

The coordinates to wrap.

required

Returns:

Type Description
ndarray

np.ndarray: The wrapped coordinates.

Source code in src/molpy/core/box.py
710
711
712
713
714
715
716
717
718
719
720
def wrap_free(self, xyz: np.ndarray) -> np.ndarray:
    """
    Wrap coordinates for a free box style.

    Args:
        xyz (np.ndarray): The coordinates to wrap.

    Returns:
        np.ndarray: The wrapped coordinates.
    """
    return xyz

wrap_orthogonal

wrap_orthogonal(xyz)

Wrap coordinates for an orthogonal box style.

Parameters:

Name Type Description Default
xyz ndarray

The coordinates to wrap.

required

Returns:

Type Description
ndarray

np.ndarray: The wrapped coordinates.

Source code in src/molpy/core/box.py
722
723
724
725
726
727
728
729
730
731
732
733
def wrap_orthogonal(self, xyz: np.ndarray) -> np.ndarray:
    """
    Wrap coordinates for an orthogonal box style.

    Args:
        xyz (np.ndarray): The coordinates to wrap.

    Returns:
        np.ndarray: The wrapped coordinates.
    """
    lengths = self.lengths  # Should be shape (3,)
    return xyz - np.floor(xyz / lengths) * lengths

wrap_triclinic

wrap_triclinic(xyz)

Wrap coordinates for a triclinic box style.

Parameters:

Name Type Description Default
xyz ndarray

The coordinates to wrap.

required

Returns:

Type Description
ndarray

np.ndarray: The wrapped coordinates.

Source code in src/molpy/core/box.py
735
736
737
738
739
740
741
742
743
744
745
746
747
748
def wrap_triclinic(self, xyz: np.ndarray) -> np.ndarray:
    """
    Wrap coordinates for a triclinic box style.

    Args:
        xyz (np.ndarray): The coordinates to wrap.

    Returns:
        np.ndarray: The wrapped coordinates.
    """
    xyz = np.atleast_2d(xyz)  # Ensure xyz is a 2D array
    frac = self.make_fractional(xyz)
    frac_wrapped = frac - np.floor(frac)
    return self.make_absolute(frac_wrapped)

Forcefield

AngleStyle

AngleStyle(name, *args, **kwargs)

Bases: Style

Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

def_type

def_type(itom, jtom, ktom, name='', **kwargs)

Define angle type

Parameters:

Name Type Description Default
itom AtomType

First atom type

required
jtom AtomType

Central atom type

required
ktom AtomType

Third atom type

required
name str

Optional name (defaults to itom-jtom-ktom)

''
**kwargs Any

Angle parameters (e.g. k, theta0, etc.)

{}
Source code in src/molpy/core/forcefield.py
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
def def_type(
    self,
    itom: AtomType,
    jtom: AtomType,
    ktom: AtomType,
    name: str = "",
    **kwargs: Any,
) -> AngleType:
    """Define angle type

    Args:
        itom: First atom type
        jtom: Central atom type
        ktom: Third atom type
        name: Optional name (defaults to itom-jtom-ktom)
        **kwargs: Angle parameters (e.g. k, theta0, etc.)
    """
    if not name:
        name = f"{itom.name}-{jtom.name}-{ktom.name}"
    at = AngleType(name, itom, jtom, ktom, **kwargs)
    self.types.add(at)
    return at

to_potential

to_potential()

Create corresponding Potential instance from AngleStyle.

Returns:

Type Description

Potential instance that accepts string type labels (from Frame).

The potential internally uses TypeIndexedArray to map type names to parameters.

Raises:

Type Description
ValueError

If corresponding Potential class not found or missing required parameters

Source code in src/molpy/core/forcefield.py
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
def to_potential(self):
    """Create corresponding Potential instance from AngleStyle.

    Returns:
        Potential instance that accepts string type labels (from Frame).
        The potential internally uses TypeIndexedArray to map type names to parameters.

    Raises:
        ValueError: If corresponding Potential class not found or missing required parameters
    """
    # Delayed import to avoid circular references

    # Get corresponding Potential class from registry
    typename = "angle"
    registry = ForceField._kernel_registry.get(typename, {})
    potential_class = registry.get(self.name)

    if potential_class is None:
        raise ValueError(
            f"Potential class not found for angle style '{self.name}'. "
            f"Available potentials: {list(registry.keys())}"
        )

    # Get all AngleTypes
    angle_types = self.types.bucket(AngleType)
    if not angle_types:
        raise ValueError(f"No angle types defined in style '{self.name}'")

    # Extract parameters as dictionaries (type name -> parameter)
    k_dict = {}
    theta0_dict = {}

    for at in angle_types:
        k = at.params.kwargs.get("k")
        theta0 = at.params.kwargs.get("theta0")

        if k is None or theta0 is None:
            raise ValueError(
                f"AngleType '{at.name}' is missing required parameters: "
                f"k={k}, theta0={theta0}"
            )

        k_dict[at.name] = k
        theta0_dict[at.name] = theta0

    # Create Potential instance with dictionaries
    # TypeIndexedArray automatically handles string type name indexing
    return potential_class(k=k_dict, theta0=theta0_dict)

AngleType

AngleType(name, itom, jtom, ktom, **kwargs)

Bases: Type

Angle type defined by three atom types

Source code in src/molpy/core/forcefield.py
381
382
383
384
385
386
387
388
389
390
391
392
def __init__(
    self,
    name: str,
    itom: "AtomType",
    jtom: "AtomType",
    ktom: "AtomType",
    **kwargs: Any,
):
    super().__init__(name, **kwargs)
    self.itom = itom
    self.jtom = jtom
    self.ktom = ktom

matches

matches(at1, at2, at3)

Check if matches given atom type triple (supports wildcards and reverse order)

Source code in src/molpy/core/forcefield.py
394
395
396
397
398
399
400
def matches(self, at1: "AtomType", at2: "AtomType", at3: "AtomType") -> bool:
    """Check if matches given atom type triple (supports wildcards and reverse order)"""
    # Forward match
    if self.itom == at1 and self.jtom == at2 and self.ktom == at3:
        return True
    # Reverse match
    return bool(self.itom == at3 and self.jtom == at2 and self.ktom == at1)

AtomStyle

AtomStyle(name, *args, **kwargs)

Bases: Style

Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

def_type

def_type(name, **kwargs)

Define atom type

Parameters:

Name Type Description Default
type_

Specific type identifier (e.g. opls_135)

required
class_

Class identifier (e.g. CT)

required
**kwargs Any

Other parameters (element, mass, etc.)

{}

Returns:

Type Description
AtomType

Created AtomType instance

Source code in src/molpy/core/forcefield.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def def_type(self, name: str, **kwargs: Any) -> AtomType:
    """Define atom type

    Args:
        type_: Specific type identifier (e.g. opls_135)
        class_: Class identifier (e.g. CT)
        **kwargs: Other parameters (element, mass, etc.)

    Returns:
        Created AtomType instance
    """
    at = AtomType(name=name, **kwargs)
    self.types.add(at)
    return at

BondStyle

BondStyle(name, *args, **kwargs)

Bases: Style

Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

def_type

def_type(itom, jtom, name='', **kwargs)

Define bond type

Parameters:

Name Type Description Default
itom AtomType

First atom type

required
jtom AtomType

Second atom type

required
name str

Optional name (defaults to itom-jtom)

''
**kwargs Any

Bond parameters (e.g. k, r0, etc.)

{}
Source code in src/molpy/core/forcefield.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
def def_type(
    self, itom: AtomType, jtom: AtomType, name: str = "", **kwargs: Any
) -> BondType:
    """Define bond type

    Args:
        itom: First atom type
        jtom: Second atom type
        name: Optional name (defaults to itom-jtom)
        **kwargs: Bond parameters (e.g. k, r0, etc.)
    """
    if not name:
        name = f"{itom.name}-{jtom.name}"
    bt = BondType(name, itom, jtom, **kwargs)
    self.types.add(bt)
    return bt

to_potential

to_potential()

Create corresponding Potential instance from BondStyle.

Returns:

Type Description

Potential instance that accepts string type labels (from Frame).

The potential internally uses dictionaries to map type names to parameters.

Raises:

Type Description
ValueError

If corresponding Potential class not found or missing required parameters

Source code in src/molpy/core/forcefield.py
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
def to_potential(self):
    """Create corresponding Potential instance from BondStyle.

    Returns:
        Potential instance that accepts string type labels (from Frame).
        The potential internally uses dictionaries to map type names to parameters.

    Raises:
        ValueError: If corresponding Potential class not found or missing required parameters
    """
    # Delayed import to avoid circular references

    # Get corresponding Potential class from registry
    typename = "bond"
    registry = ForceField._kernel_registry.get(typename, {})
    potential_class = registry.get(self.name)

    if potential_class is None:
        raise ValueError(
            f"Potential class not found for bond style '{self.name}'. "
            f"Available potentials: {list(registry.keys())}"
        )

    # Get all BondTypes
    bond_types = self.types.bucket(BondType)
    if not bond_types:
        raise ValueError(f"No bond types defined in style '{self.name}'")

    # Extract parameters as dictionaries (type name -> parameter)
    k_dict = {}
    r0_dict = {}

    for bt in bond_types:
        k = bt.params.kwargs.get("k")
        r0 = bt.params.kwargs.get("r0")

        if k is None or r0 is None:
            raise ValueError(
                f"BondType '{bt.name}' is missing required parameters: "
                f"k={k}, r0={r0}"
            )

        k_dict[bt.name] = k
        r0_dict[bt.name] = r0

    # Create Potential instance with dictionaries
    # TypeIndexedArray automatically handles string type name indexing
    return potential_class(k=k_dict, r0=r0_dict)

BondType

BondType(name, itom, jtom, **kwargs)

Bases: Type

Bond type defined by two atom types

Source code in src/molpy/core/forcefield.py
363
364
365
366
def __init__(self, name: str, itom: "AtomType", jtom: "AtomType", **kwargs: Any):
    super().__init__(name, **kwargs)
    self.itom = itom
    self.jtom = jtom

matches

matches(at1, at2)

Check if matches given atom type pair (supports wildcards and order-independent)

Source code in src/molpy/core/forcefield.py
368
369
370
371
372
def matches(self, at1: "AtomType", at2: "AtomType") -> bool:
    """Check if matches given atom type pair (supports wildcards and order-independent)"""
    return (self.itom == at1 and self.jtom == at2) or (
        self.itom == at2 and self.jtom == at1
    )

DihedralStyle

DihedralStyle(name, *args, **kwargs)

Bases: Style

Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

def_type

def_type(itom, jtom, ktom, ltom, name='', **kwargs)

Define dihedral type

Parameters:

Name Type Description Default
itom AtomType

First atom type

required
jtom AtomType

Second atom type

required
ktom AtomType

Third atom type

required
ltom AtomType

Fourth atom type

required
name str

Optional name (defaults to itom-jtom-ktom-ltom)

''
**kwargs Any

Dihedral parameters

{}
Source code in src/molpy/core/forcefield.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
def def_type(
    self,
    itom: AtomType,
    jtom: AtomType,
    ktom: AtomType,
    ltom: AtomType,
    name: str = "",
    **kwargs: Any,
) -> DihedralType:
    """Define dihedral type

    Args:
        itom: First atom type
        jtom: Second atom type
        ktom: Third atom type
        ltom: Fourth atom type
        name: Optional name (defaults to itom-jtom-ktom-ltom)
        **kwargs: Dihedral parameters
    """
    if not name:
        name = f"{itom.name}-{jtom.name}-{ktom.name}-{ltom.name}"
    dt = DihedralType(name, itom, jtom, ktom, ltom, **kwargs)
    self.types.add(dt)
    return dt

DihedralType

DihedralType(name, itom, jtom, ktom, ltom, **kwargs)

Bases: Type

Dihedral type defined by four atom types

Source code in src/molpy/core/forcefield.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
def __init__(
    self,
    name: str,
    itom: "AtomType",
    jtom: "AtomType",
    ktom: "AtomType",
    ltom: "AtomType",
    **kwargs: Any,
):
    super().__init__(name, **kwargs)
    self.itom = itom
    self.jtom = jtom
    self.ktom = ktom
    self.ltom = ltom

matches

matches(at1, at2, at3, at4)

Check if matches given atom type quadruple (supports wildcards and reverse order)

Source code in src/molpy/core/forcefield.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def matches(
    self, at1: "AtomType", at2: "AtomType", at3: "AtomType", at4: "AtomType"
) -> bool:
    """Check if matches given atom type quadruple (supports wildcards and reverse order)"""
    # Forward match
    if (
        self.itom == at1
        and self.jtom == at2
        and self.ktom == at3
        and self.ltom == at4
    ):
        return True
    # Reverse match
    return bool(
        self.itom == at4
        and self.jtom == at3
        and self.ktom == at2
        and self.ltom == at1
    )

ForceField

ForceField(name='', units='real')
Source code in src/molpy/core/forcefield.py
248
249
250
251
def __init__(self, name: str = "", units: str = "real"):
    self.name = name
    self.units = units
    self.styles = TypeBucket[Style]()

def_style

def_style(style)

Register a Style instance with the force field.

The API no longer accepts Style classes. Callers must pass an instantiated Style (e.g. ff.def_style(AtomStyle("full"))). If a style with the same runtime class and name already exists it will be returned instead of registering a duplicate.

Source code in src/molpy/core/forcefield.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def def_style(self, style: Style) -> Style:
    """Register a Style instance with the force field.

    The API no longer accepts Style classes. Callers must pass an instantiated
    Style (e.g. ``ff.def_style(AtomStyle("full"))``). If a style with the
    same runtime class and name already exists it will be returned instead of
    registering a duplicate.
    """
    if not isinstance(style, Style):
        raise TypeError(
            "def_style expects a Style instance; passing a class is no longer supported"
        )

    style_inst: Style = style
    style_cls = style_inst.__class__
    style_name = style_inst.name

    # Return existing style if one with same class/name exists
    for s in self.styles.bucket(style_cls):
        if s.name == style_name:
            return s

    # Otherwise register provided instance
    self.styles.add(style_inst)
    return style_inst

get_style_by_name

get_style_by_name(name, style_class=Style)

Get a style by name from the force field.

Parameters:

Name Type Description Default
name str

Name of the style to find

required
style_class type[S]

Class of the style to search for (defaults to Style)

Style

Returns:

Type Description
S | None

The first matching Style instance, or None if not found

Source code in src/molpy/core/forcefield.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def get_style_by_name(self, name: str, style_class: type[S] = Style) -> S | None:
    """Get a style by name from the force field.

    Args:
        name: Name of the style to find
        style_class: Class of the style to search for (defaults to Style)

    Returns:
        The first matching Style instance, or None if not found
    """
    for style in self.styles.bucket(style_class):
        if style.name == name:
            return cast(S, style)
    return None

to_potentials

to_potentials()

Create Potential instances from all styles in ForceField.

Returns:

Type Description

Potentials collection containing all created potential instances

Note

Only Styles that support to_potential() method will be converted (e.g. BondStyle, AngleStyle, PairStyle)

Source code in src/molpy/core/forcefield.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def to_potentials(self):
    """Create Potential instances from all styles in ForceField.

    Returns:
        Potentials collection containing all created potential instances

    Note:
        Only Styles that support to_potential() method will be converted (e.g. BondStyle, AngleStyle, PairStyle)
    """
    # Delayed import to avoid circular references
    from molpy.potential.base import Potentials

    potentials = Potentials()

    # Iterate over all styles and try to create corresponding potentials
    for style in self.styles.bucket(Style):
        # Check if style has to_potential method
        if hasattr(style, "to_potential"):
            try:
                # mypy cannot infer that 'style' has to_potential, so cast
                potential = cast(Any, style).to_potential()
                if potential is not None:
                    potentials.append(potential)
            except (ValueError, AttributeError):
                # Skip if creation fails (e.g. missing parameters or Potential class not found)
                # Could log warnings, but silently skip for now
                pass

    return potentials

ImproperStyle

ImproperStyle(name, *args, **kwargs)

Bases: Style

Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

def_type

def_type(itom, jtom, ktom, ltom, name='', **kwargs)

Define improper dihedral type

Parameters:

Name Type Description Default
itom AtomType

First atom type

required
jtom AtomType

Second atom type (usually central atom)

required
ktom AtomType

Third atom type

required
ltom AtomType

Fourth atom type

required
name str

Optional name (defaults to itom-jtom-ktom-ltom)

''
**kwargs Any

Improper dihedral parameters

{}
Source code in src/molpy/core/forcefield.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
def def_type(
    self,
    itom: AtomType,
    jtom: AtomType,
    ktom: AtomType,
    ltom: AtomType,
    name: str = "",
    **kwargs: Any,
) -> ImproperType:
    """Define improper dihedral type

    Args:
        itom: First atom type
        jtom: Second atom type (usually central atom)
        ktom: Third atom type
        ltom: Fourth atom type
        name: Optional name (defaults to itom-jtom-ktom-ltom)
        **kwargs: Improper dihedral parameters
    """
    if not name:
        name = f"{itom.name}-{jtom.name}-{ktom.name}-{ltom.name}"
    it = ImproperType(name, itom, jtom, ktom, ltom, **kwargs)
    self.types.add(it)
    return it

ImproperType

ImproperType(name, itom, jtom, ktom, ltom, **kwargs)

Bases: Type

Improper dihedral type defined by four atom types

Source code in src/molpy/core/forcefield.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
def __init__(
    self,
    name: str,
    itom: "AtomType",
    jtom: "AtomType",
    ktom: "AtomType",
    ltom: "AtomType",
    **kwargs: Any,
):
    super().__init__(name, **kwargs)
    self.itom = itom
    self.jtom = jtom
    self.ktom = ktom
    self.ltom = ltom

matches

matches(at1, at2, at3, at4)

Check if matches given atom type quadruple (supports wildcards)

Source code in src/molpy/core/forcefield.py
466
467
468
469
470
471
472
473
474
475
476
477
def matches(
    self, at1: "AtomType", at2: "AtomType", at3: "AtomType", at4: "AtomType"
) -> bool:
    """Check if matches given atom type quadruple (supports wildcards)"""
    # Improper typically has specific central atom, so matching rules may differ
    # Implement simple exact matching for now
    return (
        self.itom == at1
        and self.jtom == at2
        and self.ktom == at3
        and self.ltom == at4
    )

PairStyle

PairStyle(name, *args, **kwargs)

Bases: Style

Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

def_type

def_type(itom, jtom=None, name='', **kwargs)

Define non-bonded interaction type

Parameters:

Name Type Description Default
itom AtomType

First atom type

required
jtom AtomType | None

Second atom type (optional, defaults to same as itom for self-interaction)

None
name str

Optional name

''
**kwargs Any

Non-bonded parameters (e.g. sigma, epsilon, charge, etc.)

{}
Source code in src/molpy/core/forcefield.py
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
def def_type(
    self,
    itom: AtomType,
    jtom: AtomType | None = None,
    name: str = "",
    **kwargs: Any,
) -> PairType:
    """Define non-bonded interaction type

    Args:
        itom: First atom type
        jtom: Second atom type (optional, defaults to same as itom for self-interaction)
        name: Optional name
        **kwargs: Non-bonded parameters (e.g. sigma, epsilon, charge, etc.)
    """
    if jtom is None:
        jtom = itom

    if not name:
        name = itom.name if itom == jtom else f"{itom.name}-{jtom.name}"

    pt = PairType(name, itom, jtom, **kwargs)
    self.types.add(pt)
    return pt

to_potential

to_potential()

Create corresponding Potential instance from PairStyle.

Returns:

Type Description

Potential instance containing all PairType parameters

Raises:

Type Description
ValueError

If corresponding Potential class not found or missing required parameters

Source code in src/molpy/core/forcefield.py
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
def to_potential(self):
    """Create corresponding Potential instance from PairStyle.

    Returns:
        Potential instance containing all PairType parameters

    Raises:
        ValueError: If corresponding Potential class not found or missing required parameters
    """
    # Delayed import to avoid circular references

    # Get corresponding Potential class from registry
    typename = "pair"
    registry = ForceField._kernel_registry.get(typename, {})
    potential_class = registry.get(self.name)

    if potential_class is None:
        raise ValueError(
            f"Potential class not found for pair style '{self.name}'. "
            f"Available potentials: {list(registry.keys())}"
        )

    # Get all PairTypes
    pair_types = self.types.bucket(PairType)
    if not pair_types:
        raise ValueError(f"No pair types defined in style '{self.name}'")

    # Extract parameters
    epsilon_list = []
    sigma_list = []

    for pt in pair_types:
        epsilon = pt.params.kwargs.get("epsilon")
        sigma = pt.params.kwargs.get("sigma")

        if epsilon is None or sigma is None:
            raise ValueError(
                f"PairType '{pt.name}' is missing required parameters: "
                f"epsilon={epsilon}, sigma={sigma}"
            )

        epsilon_list.append(epsilon)
        sigma_list.append(sigma)

    # Create Potential instance
    import numpy as np

    return potential_class(
        epsilon=np.array(epsilon_list), sigma=np.array(sigma_list)
    )

PairType

PairType(name, *atom_types, **kwargs)

Bases: Type

Non-bonded interaction type defined by one or two atom types

Source code in src/molpy/core/forcefield.py
486
487
488
489
490
491
492
493
494
495
def __init__(self, name: str, *atom_types: "AtomType", **kwargs: Any):
    super().__init__(name, **kwargs)
    if len(atom_types) == 1:
        self.itom = atom_types[0]
        self.jtom = atom_types[0]  # Self-interaction
    elif len(atom_types) == 2:
        self.itom = atom_types[0]
        self.jtom = atom_types[1]
    else:
        raise ValueError("PairType requires 1 or 2 atom types")

matches

matches(at1, at2=None)

Check if matches given atom type pair (supports wildcards and order-independent)

Source code in src/molpy/core/forcefield.py
497
498
499
500
501
502
503
504
def matches(self, at1: "AtomType", at2: "AtomType | None" = None) -> bool:
    """Check if matches given atom type pair (supports wildcards and order-independent)"""
    if at2 is None:
        at2 = at1  # Self-interaction

    return (self.itom == at1 and self.jtom == at2) or (
        self.itom == at2 and self.jtom == at1
    )

Style

Style(name, *args, **kwargs)
Source code in src/molpy/core/forcefield.py
160
161
162
163
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self.name = name
    self.params = Parameters(*args, **kwargs)
    self.types = TypeBucket[Type]()

copy

copy()

Create a copy of this style with the same name and parameters (but not types).

Returns:

Type Description
Style

A new Style instance with copied name and parameters

Source code in src/molpy/core/forcefield.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def copy(self) -> "Style":
    """Create a copy of this style with the same name and parameters (but not types).

    Returns:
        A new Style instance with copied name and parameters
    """
    import inspect

    # Get the actual style class
    actual_style_class = type(self)

    # Get constructor signature to determine how to create the copy
    sig = inspect.signature(actual_style_class.__init__)
    param_count = len(sig.parameters) - 1  # Exclude 'self'

    # Copy parameters
    style_params = self.params.kwargs.copy()
    style_args = list(self.params.args)

    if param_count == 0:
        # Style with no parameters (shouldn't happen for base Style, but handle it)
        new_style = actual_style_class(self.name)
    elif param_count == 1:
        # Style with just name parameter
        new_style = actual_style_class(self.name)
    else:
        # Style with name and additional parameters
        new_style = actual_style_class(self.name, *style_args, **style_params)

    return new_style

get_type_by_name

get_type_by_name(name, type_class=Type)

Get a type by name from this style.

Parameters:

Name Type Description Default
name str

Name of the type to find

required
type_class type[Ty]

Class of the type to search for (defaults to Type)

Type

Returns:

Type Description
Ty | None

The first matching Type instance, or None if not found

Source code in src/molpy/core/forcefield.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def get_type_by_name(self, name: str, type_class: type[Ty] = Type) -> Ty | None:
    """Get a type by name from this style.

    Args:
        name: Name of the type to find
        type_class: Class of the type to search for (defaults to Type)

    Returns:
        The first matching Type instance, or None if not found
    """
    for type_obj in self.types.bucket(type_class):
        if type_obj.name == name:
            return cast(Ty, type_obj)
    return None

get_types

get_types(type_class)

Get all types of the specified class from this style.

Parameters:

Name Type Description Default
type_class type[Ty]

Class of the types to retrieve (e.g., AtomType, BondType)

required

Returns:

Type Description
list[Ty]

List of types of the specified class

Source code in src/molpy/core/forcefield.py
212
213
214
215
216
217
218
219
220
221
def get_types(self, type_class: type[Ty]) -> list[Ty]:
    """Get all types of the specified class from this style.

    Args:
        type_class: Class of the types to retrieve (e.g., AtomType, BondType)

    Returns:
        List of types of the specified class
    """
    return cast(list[Ty], self.types.bucket(type_class))

Type

Type(name, *args, **kwargs)
Source code in src/molpy/core/forcefield.py
62
63
64
def __init__(self, name: str, *args: Any, **kwargs: Any):
    self._name = name
    self.params = Parameters(*args, **kwargs)

copy

copy()

Create a copy of this type with the same name and parameters.

Returns:

Type Description
Type

A new Type instance with copied parameters

Source code in src/molpy/core/forcefield.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def copy(self) -> "Type":
    """Create a copy of this type with the same name and parameters.

    Returns:
        A new Type instance with copied parameters
    """
    # Get the actual type class
    actual_type_class = type(self)

    # Copy parameters
    type_params = self.params.kwargs.copy()
    type_args = list(self.params.args)

    # Handle special types with atom type references
    if isinstance(self, BondType):
        # BondType requires itom and jtom as positional args
        return actual_type_class(
            self.name, self.itom, self.jtom, *type_args, **type_params
        )
    elif isinstance(self, AngleType):
        # AngleType requires itom, jtom, ktom as positional args
        return actual_type_class(
            self.name, self.itom, self.jtom, self.ktom, *type_args, **type_params
        )
    elif isinstance(self, DihedralType):
        # DihedralType requires itom, jtom, ktom, ltom as positional args
        return actual_type_class(
            self.name,
            self.itom,
            self.jtom,
            self.ktom,
            self.ltom,
            *type_args,
            **type_params,
        )
    elif isinstance(self, ImproperType):
        # ImproperType requires itom, jtom, ktom, ltom as positional args
        return actual_type_class(
            self.name,
            self.itom,
            self.jtom,
            self.ktom,
            self.ltom,
            *type_args,
            **type_params,
        )
    elif isinstance(self, PairType):
        # PairType requires atom types as positional args
        if self.itom == self.jtom:
            return actual_type_class(
                self.name, self.itom, *type_args, **type_params
            )
        else:
            return actual_type_class(
                self.name, self.itom, self.jtom, *type_args, **type_params
            )
    else:
        # Regular Type (e.g., AtomType) - just name and kwargs
        return actual_type_class(self.name, *type_args, **type_params)

Frame

Block

Block(vars_=None)

Bases: MutableMapping[str, ndarray]

Lightweight container that maps variable names -> NumPy arrays.

• Behaves like a dict but auto-casts any assigned value to ndarray. • All built-in dict/MutableMapping helpers work out of the box. • Supports advanced indexing: by key, by index/slice, by mask, by list of keys.

Parameters

vars_ : dict[str, ArrayLike] or None, optional Initial data to populate the Block. Keys are variable names, values are array-like data that will be converted to numpy arrays.

Examples

Create and access basic data:

blk = Block() blk["x"] = [0.0, 1.0, 2.0] blk["y"] = [0.0, 0.0, 0.0] "x" in blk True len(blk) 2 blk["x"].dtype dtype('float64')

Multiple indexing methods:

blk = Block({"id": [1, 2, 3], "x": [10.0, 20.0, 30.0]}) blk[0] # Access single row, returns dict {'id': 1, 'x': 10.0} blk[0:2] # Slice access {'id': array([1, 2]), 'x': array([10., 20.])} blk[["id", "x"]] # Multi-column access, returns 2D array (requires same dtype) Traceback (most recent call last): ... ValueError: Arrays must have the same dtype...

Using boolean masks for filtering:

blk = Block({"id": [1, 2, 3, 4, 5], "mol": [1, 1, 2, 2, 3]}) mask = blk["mol"] < 3 sub_blk = blk[mask] sub_blk["id"] array([1, 2, 3, 4]) sub_blk.nrows 4

Sorting:

blk = Block({"x": [3, 1, 2], "y": [30, 10, 20]}) sorted_blk = blk.sort("x") # Returns new Block sorted_blk["x"] array([1, 2, 3]) _ = blk.sort_("x") # In-place sort, returns self blk["x"] array([1, 2, 3])

Source code in src/molpy/core/frame.py
79
80
81
82
83
84
85
86
87
88
89
90
def __init__(self, vars_: BlockLike | None = None) -> None:
    self._vars: dict[str, np.ndarray] = {k: np.asarray(v) for k, v in {}.items()}
    if vars_ is not None:
        if not isinstance(vars_, dict):
            raise ValueError(f"vars_ must be a dict, got {type(vars_)}")
        for k, v in vars_.items():
            try:
                self._vars[k] = np.asarray(v)
            except Exception as e:
                raise ValueError(
                    f"Value must be a BlockLike, i.e. dict[str, np.ndarray], but got {type(v)} for key {k}"
                ) from e

nrows property

nrows

Return the number of rows in the first variable (if any).

shape property

shape

Return the shape of the first variable (if any).

copy

copy()

Shallow copy (arrays are not copied).

Source code in src/molpy/core/frame.py
293
294
295
def copy(self) -> "Block":
    """Shallow copy (arrays are **not** copied)."""
    return Block(self._vars.copy())  # type: ignore[arg-type]

from_csv classmethod

from_csv(filepath, *, delimiter=',', encoding='utf-8', header=None, **kwargs)

Create a Block from a CSV file or StringIO.

Parameters

filepath : str, Path, or StringIO Path to the CSV file or StringIO object delimiter : str, default="," CSV delimiter character encoding : str, default="utf-8" File encoding (ignored for StringIO) header : list[str] or None, default=None Column names. If None, first row is used as headers. If provided, CSV is assumed to have no header row. **kwargs Additional arguments passed to csv.reader

Returns

Block A new Block instance with data from the CSV file

Examples

Read from StringIO:

from io import StringIO csv_data = StringIO("x,y,z\n0,1,2\n3,4,5") block = Block.from_csv(csv_data) block["x"] array([0, 3]) block.nrows 2

CSV without header:

csv_no_header = StringIO("0,1,2\n3,4,5") block = Block.from_csv(csv_no_header, header=["x", "y", "z"]) list(block.keys()) ['x', 'y', 'z'] block.nrows 2

Source code in src/molpy/core/frame.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
@classmethod
def from_csv(
    cls,
    filepath: str | Path | StringIO,
    *,
    delimiter: str = ",",
    encoding: str = "utf-8",
    header: list[str] | None = None,
    **kwargs,
) -> "Block":
    """
    Create a Block from a CSV file or StringIO.

    Parameters
    ----------
    filepath : str, Path, or StringIO
        Path to the CSV file or StringIO object
    delimiter : str, default=","
        CSV delimiter character
    encoding : str, default="utf-8"
        File encoding (ignored for StringIO)
    header : list[str] or None, default=None
        Column names. If None, first row is used as headers.
        If provided, CSV is assumed to have no header row.
    **kwargs
        Additional arguments passed to csv.reader

    Returns
    -------
    Block
        A new Block instance with data from the CSV file

    Examples
    --------
    Read from StringIO:

    >>> from io import StringIO
    >>> csv_data = StringIO("x,y,z\\n0,1,2\\n3,4,5")
    >>> block = Block.from_csv(csv_data)
    >>> block["x"]
    array([0, 3])
    >>> block.nrows
    2

    CSV without header:

    >>> csv_no_header = StringIO("0,1,2\\n3,4,5")
    >>> block = Block.from_csv(csv_no_header, header=["x", "y", "z"])
    >>> list(block.keys())
    ['x', 'y', 'z']
    >>> block.nrows
    2
    """
    # Determine type
    if isinstance(filepath, StringIO):
        csvfile = filepath
        csvfile.seek(0)
        close_file = False
    else:
        filepath = Path(filepath)
        if not filepath.exists():
            raise FileNotFoundError(f"CSV file not found: {filepath}")
        csvfile = open(filepath, encoding=encoding, newline="")
        close_file = True

    try:
        reader = csv.reader(csvfile, delimiter=delimiter, **kwargs)

        # Handle headers
        if header is None:
            # Use first row as headers
            try:
                headers = next(reader)
            except StopIteration:
                raise ValueError("CSV file is empty")
        else:
            # Use provided headers, no header row in CSV
            headers = header

        raw_data = {h: [] for h in headers}

        for row in reader:
            for i, header_name in enumerate(headers):
                raw_data[header_name].append(row[i])

        data = {}
        for k, v in raw_data.items():
            for dtype in (int, float, str):
                try:
                    data[k] = np.array(v, dtype=dtype)
                    break
                except ValueError:
                    continue
            else:
                raise ValueError(f"Failed to convert {k} to any of int, float, str")

        return cls(data)
    finally:
        if close_file:
            csvfile.close()

from_dict classmethod

from_dict(data)

Inverse of :meth:to_dict.

Source code in src/molpy/core/frame.py
187
188
189
190
@classmethod
def from_dict(cls, data: dict[str, np.ndarray]) -> "Block":
    """Inverse of :meth:`to_dict`."""
    return cls({k: np.asarray(v) for k, v in data.items()})

iterrows

iterrows(n=None)

Iterate over rows of the block.

Returns

Iterator[tuple[int, dict[str, Any]]] An iterator yielding (index, row_data) pairs where: - index: int, the row index - row_data: dict, mapping variable names to their values for this row

Examples

blk = Block({ ... "id": [1, 2, 3], ... "type": ["C", "O", "N"], ... "x": [0.0, 1.0, 2.0], ... "y": [0.0, 0.0, 1.0], ... "z": [0.0, 0.0, 0.0] ... }) for index, row in blk.iterrows(): # doctest: +SKIP ... print(f"Row {index}: {row}") Row 0: {'id': 1, 'type': 'C', 'x': 0.0, 'y': 0.0, 'z': 0.0} Row 1: {'id': 2, 'type': 'O', 'x': 1.0, 'y': 0.0, 'z': 0.0} Row 2: {'id': 3, 'type': 'N', 'x': 2.0, 'y': 1.0, 'z': 0.0}

Notes

This method is similar to pandas DataFrame.iterrows() but returns a dictionary for each row instead of a pandas Series.

Source code in src/molpy/core/frame.py
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
def iterrows(self, n: int | None = None) -> Iterator[tuple[int, dict[str, Any]]]:
    """
    Iterate over rows of the block.

    Returns
    -------
    Iterator[tuple[int, dict[str, Any]]]
        An iterator yielding (index, row_data) pairs where:
        - index: int, the row index
        - row_data: dict, mapping variable names to their values for this row

    Examples
    --------
    >>> blk = Block({
    ...     "id": [1, 2, 3],
    ...     "type": ["C", "O", "N"],
    ...     "x": [0.0, 1.0, 2.0],
    ...     "y": [0.0, 0.0, 1.0],
    ...     "z": [0.0, 0.0, 0.0]
    ... })
    >>> for index, row in blk.iterrows():  # doctest: +SKIP
    ...     print(f"Row {index}: {row}")
    Row 0: {'id': 1, 'type': 'C', 'x': 0.0, 'y': 0.0, 'z': 0.0}
    Row 1: {'id': 2, 'type': 'O', 'x': 1.0, 'y': 0.0, 'z': 0.0}
    Row 2: {'id': 3, 'type': 'N', 'x': 2.0, 'y': 1.0, 'z': 0.0}

    Notes
    -----
    This method is similar to pandas DataFrame.iterrows() but returns
    a dictionary for each row instead of a pandas Series.
    """
    if not self._vars:
        return

    # Get the number of rows from the first variable
    nrows = self.nrows if n is None else n
    if nrows == 0:
        return

    # Get all variable names
    var_names = list(self._vars.keys())

    for i in range(nrows):
        row_data = {}
        for var_name in var_names:
            var_data = self._vars[var_name]
            if i < len(var_data):
                # Handle scalar values
                if var_data.ndim == 0:
                    row_data[var_name] = var_data.item()
                else:
                    row_data[var_name] = var_data[i]
            else:
                # Handle case where variable has fewer rows
                row_data[var_name] = None

        yield i, row_data

itertuples

itertuples(index=True, name='Row')

Iterate over rows of the block as named tuples.

Parameters

index : bool, default=True If True, include the row index as the first element name : str, default="Row" The name of the named tuple class

Returns

Iterator[Any] An iterator yielding named tuples for each row

Examples

blk = Block({ ... "id": [1, 2, 3], ... "type": ["C", "O", "N"], ... "x": [0.0, 1.0, 2.0] ... }) for row in blk.itertuples(): ... print(f"Index: {row.Index}, ID: {row.id}, Type: {row.type}") Index: 0, ID: 1, Type: C Index: 1, ID: 2, Type: O Index: 2, ID: 3, Type: N

Notes

This method is similar to pandas DataFrame.itertuples().

Source code in src/molpy/core/frame.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
def itertuples(self, index: bool = True, name: str = "Row") -> Iterator[Any]:
    """
    Iterate over rows of the block as named tuples.

    Parameters
    ----------
    index : bool, default=True
        If True, include the row index as the first element
    name : str, default="Row"
        The name of the named tuple class

    Returns
    -------
    Iterator[Any]
        An iterator yielding named tuples for each row

    Examples
    --------
    >>> blk = Block({
    ...     "id": [1, 2, 3],
    ...     "type": ["C", "O", "N"],
    ...     "x": [0.0, 1.0, 2.0]
    ... })
    >>> for row in blk.itertuples():
    ...     print(f"Index: {row.Index}, ID: {row.id}, Type: {row.type}")
    Index: 0, ID: 1, Type: C
    Index: 1, ID: 2, Type: O
    Index: 2, ID: 3, Type: N

    Notes
    -----
    This method is similar to pandas DataFrame.itertuples().
    """
    from collections import namedtuple

    if not self._vars:
        return

    # Get the number of rows from the first variable
    nrows = self.nrows
    if nrows == 0:
        return

    # Get all variable names
    var_names = list(self._vars.keys())

    # Create field names for the named tuple
    field_names = ["Index", *var_names] if index else var_names

    # Create the named tuple class
    RowTuple = namedtuple(name, field_names)

    for i in range(nrows):
        row_values = []
        if index:
            row_values.append(i)

        for var_name in var_names:
            var_data = self._vars[var_name]
            if i < len(var_data):
                # Handle scalar values
                if var_data.ndim == 0:
                    row_values.append(var_data.item())
                else:
                    row_values.append(var_data[i])
            else:
                # Handle case where variable has fewer rows
                row_values.append(None)

        yield RowTuple(*row_values)

sort

sort(key, *, reverse=False)

Sort the block by a specific variable and return a new sorted Block.

This method creates a new Block instance with sorted data, leaving the original Block unchanged.

Parameters:

Name Type Description Default
key str

The variable name to sort by.

required
reverse bool

If True, sort in descending order. Defaults to False.

False

Returns:

Type Description
Block

A new Block with sorted data.

Raises:

Type Description
KeyError

If the key variable doesn't exist in the block.

ValueError

If any variable has different length than the key variable.

Example

blk = Block({"x": [3, 1, 2], "y": [30, 10, 20]}) sorted_blk = blk.sort("x") sorted_blk["x"] array([1, 2, 3]) sorted_blk["y"] array([10, 20, 30])

Original block is unchanged

blk["x"] array([3, 1, 2])

Source code in src/molpy/core/frame.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def sort(self, key: str, *, reverse: bool = False) -> "Block":
    """Sort the block by a specific variable and return a new sorted Block.

    This method creates a new Block instance with sorted data, leaving the
    original Block unchanged.

    Args:
        key: The variable name to sort by.
        reverse: If True, sort in descending order. Defaults to False.

    Returns:
        A new Block with sorted data.

    Raises:
        KeyError: If the key variable doesn't exist in the block.
        ValueError: If any variable has different length than the key variable.

    Example:
        >>> blk = Block({"x": [3, 1, 2], "y": [30, 10, 20]})
        >>> sorted_blk = blk.sort("x")
        >>> sorted_blk["x"]
        array([1, 2, 3])
        >>> sorted_blk["y"]
        array([10, 20, 30])
        >>> # Original block is unchanged
        >>> blk["x"]
        array([3, 1, 2])
    """
    sorted_vars = self._sort(key, reverse=reverse)
    return Block(sorted_vars)

sort_

sort_(key, *, reverse=False)

Sort the block in-place by a specific variable.

This method modifies the current Block instance by sorting all variables according to the specified key. The original data is overwritten.

Parameters:

Name Type Description Default
key str

The variable name to sort by.

required
reverse bool

If True, sort in descending order. Defaults to False.

False

Returns:

Type Description
Self

Self (for method chaining).

Raises:

Type Description
KeyError

If the key variable doesn't exist in the block.

ValueError

If any variable has different length than the key variable.

Example

blk = Block({"x": [3, 1, 2], "y": [30, 10, 20]}) _ = blk.sort_("x") # Returns self for chaining blk["x"] array([1, 2, 3]) blk["y"] array([10, 20, 30])

Original data is now sorted

Source code in src/molpy/core/frame.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def sort_(self, key: str, *, reverse: bool = False) -> "Self":
    """Sort the block in-place by a specific variable.

    This method modifies the current Block instance by sorting all variables
    according to the specified key. The original data is overwritten.

    Args:
        key: The variable name to sort by.
        reverse: If True, sort in descending order. Defaults to False.

    Returns:
        Self (for method chaining).

    Raises:
        KeyError: If the key variable doesn't exist in the block.
        ValueError: If any variable has different length than the key variable.

    Example:
        >>> blk = Block({"x": [3, 1, 2], "y": [30, 10, 20]})
        >>> _ = blk.sort_("x")  # Returns self for chaining
        >>> blk["x"]
        array([1, 2, 3])
        >>> blk["y"]
        array([10, 20, 30])
        >>> # Original data is now sorted
    """
    sorted_vars = self._sort(key, reverse=reverse)
    if sorted_vars:  # Only update if we have data to sort
        self._vars.update(sorted_vars)
    return self

to_dict

to_dict()

Return a JSON-serialisable copy (arrays -> Python lists).

Source code in src/molpy/core/frame.py
183
184
185
def to_dict(self) -> dict[str, np.ndarray]:
    """Return a JSON-serialisable copy (arrays -> Python lists)."""
    return {k: v for k, v in self._vars.items()}

Frame

Frame(blocks=None, **props)

Hierarchical numerical data container with named blocks.

Frame stores multiple Block objects under string keys (e.g., "atoms", "bonds") and allows arbitrary metadata to be attached. It's designed for molecular simulation data where different entity types need separate tabular storage.

Structure

Frame ├─ blocks: dict[str, Block] # Named data blocks └─ metadata: dict[str, Any] # Arbitrary metadata (box, timestep, etc.)

Parameters:

Name Type Description Default
blocks dict[str, Block | dict] | None

Initial blocks. If a dict value is not a Block, it will be converted.

None
**props Any

Arbitrary keyword arguments stored in metadata.

{}

Examples:

Create Frame and add data blocks:

>>> frame = Frame()
>>> frame["atoms"] = Block({"x": [0.0, 1.0], "y": [0.0, 0.0], "z": [0.0, 0.0]})
>>> frame["atoms"]["x"]
array([0., 1.])
>>> frame["atoms"].nrows
2

Initialize with nested dictionaries:

>>> frame = Frame(blocks={
...     "atoms": {"id": [1, 2, 3], "type": ["C", "H", "H"]},
...     "bonds": {"i": [0, 0], "j": [1, 2]}
... })
>>> list(frame._blocks)
['atoms', 'bonds']
>>> frame["atoms"]["id"]
array([1, 2, 3])

Add metadata:

>>> frame = Frame()
>>> frame.metadata["timestep"] = 0
>>> frame.metadata["description"] = "Test system"
>>> frame.metadata["timestep"]
0
>>> frame.metadata["description"]
'Test system'

Chained access:

>>> frame = Frame(blocks={"atoms": {"x": [1, 2, 3], "y": [4, 5, 6]}})
>>> atoms = frame["atoms"]
>>> xyz_combined = atoms[["x", "y"]]
>>> xyz_combined.shape
(3, 2)

Iterate over all blocks and variables:

>>> frame = Frame(blocks={
...     "atoms": {"id": [1, 2], "mass": [12.0, 1.0]},
...     "bonds": {"i": [0], "j": [1]}
... })
>>> for block_name in frame._blocks:
...     print(f"{block_name}: {list(frame[block_name].keys())}")
atoms: ['id', 'mass']
bonds: ['i', 'j']

Initialize a Frame with optional blocks and metadata.

Parameters:

Name Type Description Default
blocks dict[str, Block | BlockLike] | None

Initial blocks. If a dict value is not a Block, it will be converted to a Block. Defaults to None.

None
**props Any

Arbitrary keyword arguments stored in metadata.

{}
Source code in src/molpy/core/frame.py
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
def __init__(
    self,
    blocks: dict[str, Block | BlockLike] | None = None,
    **props: Any,
) -> None:
    """Initialize a Frame with optional blocks and metadata.

    Args:
        blocks (dict[str, Block | BlockLike] | None, optional): Initial
            blocks. If a dict value is not a Block, it will be converted to
            a Block. Defaults to None.
        **props (Any): Arbitrary keyword arguments stored in metadata.
    """
    # guarantee a root block even if none supplied
    self._blocks: dict[str, Block] = {}
    if blocks is not None:
        self._blocks = self._validate_and_convert_blocks(blocks)
    self.metadata: dict[str, Any] = props

blocks property

blocks

Iterate over stored Block objects.

Returns:

Type Description
Iterator[Block]

Iterator[Block]: Iterator over Block values stored in this Frame.

Note

To iterate over block names use for name in frame._blocks or frame._blocks.keys().

Examples:

>>> frame = Frame(blocks={"atoms": {"x": [1]}, "bonds": {"i": [0]}})
>>> [b for b in frame.blocks]
[Block(x: shape=(1,), i: shape=(1,))]

copy

copy()

Create a shallow copy of the Frame.

Blocks are copied (shallow), but the underlying numpy arrays are not.

Returns:

Name Type Description
Frame Frame

A new Frame instance with copied blocks and metadata.

Examples:

>>> frame = Frame(blocks={"atoms": {"x": [1, 2, 3]}}, timestep=0)
>>> frame_copy = frame.copy()
>>> frame_copy.metadata["timestep"] = 1
>>> frame.metadata["timestep"]  # Original unchanged
0
Source code in src/molpy/core/frame.py
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
def copy(self) -> "Frame":
    """Create a shallow copy of the Frame.

    Blocks are copied (shallow), but the underlying numpy arrays are not.

    Returns:
        Frame: A new Frame instance with copied blocks and metadata.

    Examples:
        >>> frame = Frame(blocks={"atoms": {"x": [1, 2, 3]}}, timestep=0)
        >>> frame_copy = frame.copy()
        >>> frame_copy.metadata["timestep"] = 1
        >>> frame.metadata["timestep"]  # Original unchanged
        0
    """
    # Copy blocks (shallow copy of Block objects)
    new_blocks = {name: block.copy() for name, block in self._blocks.items()}
    # Create new frame
    new_frame = Frame(blocks=new_blocks)
    # Copy metadata (shallow copy of dict)
    new_frame.metadata = self.metadata.copy()
    return new_frame

from_dict classmethod

from_dict(data)

Create a Frame from a dictionary representation.

Parameters:

Name Type Description Default
data dict[str, Any]

Dictionary containing "blocks" and optionally "metadata" keys.

required

Returns:

Name Type Description
Frame Frame

A new Frame instance reconstructed from the dictionary.

Examples:

>>> data = {
...     "blocks": {"atoms": {"x": [1, 2, 3]}},
...     "metadata": {"timestep": 0}
... }
>>> frame = Frame.from_dict(data)
>>> frame["atoms"]["x"]
array([1, 2, 3])
>>> frame.metadata["timestep"]
0
Source code in src/molpy/core/frame.py
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Frame":
    """Create a Frame from a dictionary representation.

    Args:
        data (dict[str, Any]): Dictionary containing "blocks" and optionally
            "metadata" keys.

    Returns:
        Frame: A new Frame instance reconstructed from the dictionary.

    Examples:
        >>> data = {
        ...     "blocks": {"atoms": {"x": [1, 2, 3]}},
        ...     "metadata": {"timestep": 0}
        ... }
        >>> frame = Frame.from_dict(data)
        >>> frame["atoms"]["x"]
        array([1, 2, 3])
        >>> frame.metadata["timestep"]
        0
    """
    blocks = {g: Block.from_dict(grp) for g, grp in data["blocks"].items()}
    frame = cls(blocks=blocks)
    frame.metadata = data.get("metadata", {})
    return frame

to_dict

to_dict()

Convert Frame to a dictionary representation.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Dictionary containing "blocks" and "metadata" keys. Blocks are converted to dictionaries via Block.to_dict().

Examples:

>>> frame = Frame(blocks={"atoms": {"x": [1, 2]}}, timestep=0)
>>> data = frame.to_dict()
>>> "blocks" in data
True
>>> "metadata" in data
True
Source code in src/molpy/core/frame.py
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
def to_dict(self) -> dict[str, Any]:
    """Convert Frame to a dictionary representation.

    Returns:
        dict[str, Any]: Dictionary containing "blocks" and "metadata" keys.
            Blocks are converted to dictionaries via Block.to_dict().

    Examples:
        >>> frame = Frame(blocks={"atoms": {"x": [1, 2]}}, timestep=0)
        >>> data = frame.to_dict()
        >>> "blocks" in data
        True
        >>> "metadata" in data
        True
    """
    block_dict = {g: grp.to_dict() for g, grp in self._blocks.items()}
    meta_dict = {k: v for k, v in self.metadata.items()}
    return {"blocks": block_dict, "metadata": meta_dict}

Script

Script - Editable script management with filesystem and URL support.

This module provides a Script class for managing script content that can be stored locally or loaded from URLs. It supports editing, formatting, and filesystem operations without any execution logic.

Script dataclass

Script(name, language='bash', description=None, _lines=list(), path=None, url=None, tags=set())

Represents an editable script with filesystem and URL support.

This class manages script content, metadata, and filesystem operations. It does NOT provide execution logic - only content management.

Attributes:

Name Type Description
name str

Logical name of the script

language ScriptLanguage

Script language type

description str | None

Optional human-readable description

_lines list[str]

Internal storage for multi-line content

path Path | None

Local file path if stored on disk

url str | None

URL the script was loaded from (if any)

tags set[str]

Optional lightweight tag system

lines property

lines

Get a copy of all script lines.

Returns:

Type Description
list[str]

Copy of internal lines list

text property

text

Get the full script as a single string.

Returns:

Type Description
str

Script content with lines joined by newlines, with exactly one trailing newline

append

append(line='')

Append a single line to the end of the script.

Parameters:

Name Type Description Default
line str

Line content to append

''
Source code in src/molpy/core/script.py
238
239
240
241
242
243
244
245
def append(self, line: str = "") -> None:
    """
    Append a single line to the end of the script.

    Args:
        line: Line content to append
    """
    self._lines.append(line)

append_block

append_block(block)

Append a multi-line block to the script.

The block is dedented, trailing newlines are stripped, and then split into lines.

Parameters:

Name Type Description Default
block str

Multi-line string block to append

required
Source code in src/molpy/core/script.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def append_block(self, block: str) -> None:
    """
    Append a multi-line block to the script.

    The block is dedented, trailing newlines are stripped,
    and then split into lines.

    Args:
        block: Multi-line string block to append
    """
    normalized = textwrap.dedent(block)
    normalized = normalized.rstrip("\n")
    if normalized:
        self._lines.extend(normalized.splitlines())

clear

clear()

Remove all lines from the script.

Source code in src/molpy/core/script.py
234
235
236
def clear(self) -> None:
    """Remove all lines from the script."""
    self._lines.clear()

delete

delete(index)

Delete the line at the given index.

Parameters:

Name Type Description Default
index int

0-based index of line to delete

required

Raises:

Type Description
IndexError

If index is out of range

Source code in src/molpy/core/script.py
299
300
301
302
303
304
305
306
307
308
309
310
311
def delete(self, index: int) -> None:
    """
    Delete the line at the given index.

    Args:
        index: 0-based index of line to delete

    Raises:
        IndexError: If index is out of range
    """
    if index < 0 or index >= len(self._lines):
        raise IndexError(f"Line index {index} out of range [0, {len(self._lines)})")
    del self._lines[index]

delete_file

delete_file()

Delete the script file from the filesystem.

Raises:

Type Description
ValueError

If script has no associated path

FileNotFoundError

If the file does not exist

OSError

If the file cannot be deleted

Source code in src/molpy/core/script.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def delete_file(self) -> None:
    """
    Delete the script file from the filesystem.

    Raises:
        ValueError: If script has no associated path
        FileNotFoundError: If the file does not exist
        OSError: If the file cannot be deleted
    """
    if self.path is None:
        raise ValueError("Cannot delete: script has no associated path")

    if not self.path.exists():
        raise FileNotFoundError(f"Script file not found: {self.path}")

    try:
        self.path.unlink()
    except Exception as e:
        raise OSError(f"Failed to delete script file {self.path}: {e}") from e

    # Clear the path reference
    self.path = None

extend

extend(lines)

Append multiple lines in order to the end of the script.

Parameters:

Name Type Description Default
lines Iterable[str]

Iterable of lines to append

required
Source code in src/molpy/core/script.py
247
248
249
250
251
252
253
254
def extend(self, lines: Iterable[str]) -> None:
    """
    Append multiple lines in order to the end of the script.

    Args:
        lines: Iterable of lines to append
    """
    self._lines.extend(lines)

format

format(**kwargs)

Apply string formatting to all lines and return a new Script.

Uses Python's str.format(**kwargs) on each line.

Parameters:

Name Type Description Default
**kwargs Any

Format arguments

{}

Returns:

Type Description
Script

New Script instance with formatted lines

Source code in src/molpy/core/script.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def format(self, **kwargs: Any) -> "Script":
    """
    Apply string formatting to all lines and return a new Script.

    Uses Python's str.format(**kwargs) on each line.

    Args:
        **kwargs: Format arguments

    Returns:
        New Script instance with formatted lines
    """
    formatted_lines = [line.format(**kwargs) for line in self._lines]

    return Script(
        name=self.name,
        language=self.language,
        description=self.description,
        _lines=formatted_lines,
        path=self.path,
        url=self.url,
        tags=self.tags.copy(),
    )

format_with_mapping

format_with_mapping(mapping)

Apply string formatting to all lines using a mapping and return a new Script.

Uses Python's str.format_map(mapping) on each line.

Parameters:

Name Type Description Default
mapping Mapping[str, Any]

Format mapping

required

Returns:

Type Description
Script

New Script instance with formatted lines

Source code in src/molpy/core/script.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def format_with_mapping(self, mapping: Mapping[str, Any]) -> "Script":
    """
    Apply string formatting to all lines using a mapping and return a new Script.

    Uses Python's str.format_map(mapping) on each line.

    Args:
        mapping: Format mapping

    Returns:
        New Script instance with formatted lines
    """
    formatted_lines = [line.format_map(mapping) for line in self._lines]

    return Script(
        name=self.name,
        language=self.language,
        description=self.description,
        _lines=formatted_lines,
        path=self.path,
        url=self.url,
        tags=self.tags.copy(),
    )

from_path classmethod

from_path(path, *, language=None, description=None)

Create a Script from a local file path.

Parameters:

Name Type Description Default
path str | Path

Path to the script file

required
language ScriptLanguage | None

Optional language override. If None, guessed from extension

None
description str | None

Optional description

None

Returns:

Type Description
Script

Script instance loaded from file

Raises:

Type Description
FileNotFoundError

If the file does not exist

IOError

If the file cannot be read

Source code in src/molpy/core/script.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
@classmethod
def from_path(
    cls,
    path: str | Path,
    *,
    language: ScriptLanguage | None = None,
    description: str | None = None,
) -> "Script":
    """
    Create a Script from a local file path.

    Args:
        path: Path to the script file
        language: Optional language override. If None, guessed from extension
        description: Optional description

    Returns:
        Script instance loaded from file

    Raises:
        FileNotFoundError: If the file does not exist
        IOError: If the file cannot be read
    """
    path_obj = Path(path)

    if not path_obj.exists():
        raise FileNotFoundError(f"Script file not found: {path_obj}")

    # Read file content
    try:
        content = path_obj.read_text(encoding="utf-8")
    except Exception as e:
        raise OSError(f"Failed to read script file {path_obj}: {e}") from e

    # Derive name from stem
    name = path_obj.stem

    # Guess language if not provided
    if language is None:
        language = cls._guess_language(path_obj)

    return cls.from_text(
        name=name,
        text=content,
        language=language,
        description=description,
        path=path_obj,
    )

from_text classmethod

from_text(name, text, *, language='bash', description=None, path=None, url=None)

Create a Script from text content.

Parameters:

Name Type Description Default
name str

Logical name of the script

required
text str

Multi-line text content

required
language ScriptLanguage

Script language type

'bash'
description str | None

Optional description

None
path str | Path | None

Optional local file path

None
url str | None

Optional URL source

None

Returns:

Type Description
Script

Script instance with normalized content

Source code in src/molpy/core/script.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@classmethod
def from_text(
    cls,
    name: str,
    text: str,
    *,
    language: ScriptLanguage = "bash",
    description: str | None = None,
    path: str | Path | None = None,
    url: str | None = None,
) -> "Script":
    """
    Create a Script from text content.

    Args:
        name: Logical name of the script
        text: Multi-line text content
        language: Script language type
        description: Optional description
        path: Optional local file path
        url: Optional URL source

    Returns:
        Script instance with normalized content
    """
    # Normalize indentation and split into lines
    normalized = textwrap.dedent(text)
    # Remove trailing newlines but preserve internal ones
    normalized = normalized.rstrip("\n")
    lines = normalized.splitlines() if normalized else []

    path_obj = Path(path) if path is not None else None

    return cls(
        name=name,
        language=language,
        description=description,
        _lines=lines,
        path=path_obj,
        url=url,
    )

from_url classmethod

from_url(url, *, name=None, language='other', description=None)

Create a Script from a URL.

Parameters:

Name Type Description Default
url str

URL to fetch the script from

required
name str | None

Optional name. If None, derived from URL

None
language ScriptLanguage

Script language type

'other'
description str | None

Optional description

None

Returns:

Type Description
Script

Script instance loaded from URL

Raises:

Type Description
URLError

If the URL cannot be fetched

Source code in src/molpy/core/script.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
@classmethod
def from_url(
    cls,
    url: str,
    *,
    name: str | None = None,
    language: ScriptLanguage = "other",
    description: str | None = None,
) -> "Script":
    """
    Create a Script from a URL.

    Args:
        url: URL to fetch the script from
        name: Optional name. If None, derived from URL
        language: Script language type
        description: Optional description

    Returns:
        Script instance loaded from URL

    Raises:
        urllib.error.URLError: If the URL cannot be fetched
    """
    try:
        with urllib.request.urlopen(url) as response:
            content = response.read().decode("utf-8")
    except Exception as e:
        raise urllib.error.URLError(
            f"Failed to fetch script from {url}: {e}"
        ) from e

    # Derive name from URL if not provided
    if name is None:
        # Extract last path segment
        from urllib.parse import urlparse

        parsed = urlparse(url)
        path_segment = parsed.path.rstrip("/").split("/")[-1]
        if path_segment:
            # Remove extension if present
            name = Path(path_segment).stem or "script"
        else:
            name = "script"

    return cls.from_text(
        name=name,
        text=content,
        language=language,
        description=description,
        url=url,
    )

insert

insert(index, line)

Insert a single line at the given index.

Parameters:

Name Type Description Default
index int

0-based index where to insert

required
line str

Line content to insert

required

Raises:

Type Description
IndexError

If index is out of range

Source code in src/molpy/core/script.py
271
272
273
274
275
276
277
278
279
280
281
282
def insert(self, index: int, line: str) -> None:
    """
    Insert a single line at the given index.

    Args:
        index: 0-based index where to insert
        line: Line content to insert

    Raises:
        IndexError: If index is out of range
    """
    self._lines.insert(index, line)

move

move(new_path)

Move the script file to a new location.

Parameters:

Name Type Description Default
new_path str | Path

New file path

required

Returns:

Type Description
Path

New path where the script was moved

Raises:

Type Description
ValueError

If script has no associated path

FileNotFoundError

If the original file does not exist

OSError

If the file cannot be moved

Source code in src/molpy/core/script.py
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
def move(self, new_path: str | Path) -> Path:
    """
    Move the script file to a new location.

    Args:
        new_path: New file path

    Returns:
        New path where the script was moved

    Raises:
        ValueError: If script has no associated path
        FileNotFoundError: If the original file does not exist
        OSError: If the file cannot be moved
    """
    if self.path is None:
        raise ValueError("Cannot move: script has no associated path")

    if not self.path.exists():
        raise FileNotFoundError(f"Script file not found: {self.path}")

    new_path_obj = Path(new_path)

    # Ensure parent directory exists
    new_path_obj.parent.mkdir(parents=True, exist_ok=True)

    try:
        self.path.rename(new_path_obj)
    except Exception as e:
        raise OSError(
            f"Failed to move script from {self.path} to {new_path_obj}: {e}"
        ) from e

    # Update internal path
    self.path = new_path_obj

    return new_path_obj

preview

preview(max_lines=20, *, with_line_numbers=True)

Generate a preview of the script.

Parameters:

Name Type Description Default
max_lines int

Maximum number of lines to show

20
with_line_numbers bool

Whether to include line numbers

True

Returns:

Type Description
str

Preview string

Source code in src/molpy/core/script.py
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
def preview(
    self,
    max_lines: int = 20,
    *,
    with_line_numbers: bool = True,
) -> str:
    """
    Generate a preview of the script.

    Args:
        max_lines: Maximum number of lines to show
        with_line_numbers: Whether to include line numbers

    Returns:
        Preview string
    """
    if not self._lines:
        return "(empty script)"

    lines_to_show = self._lines[:max_lines]
    has_more = len(self._lines) > max_lines

    if with_line_numbers:
        # Calculate width for line numbers
        max_num = len(self._lines) if has_more else len(lines_to_show)
        width = len(str(max_num))

        preview_lines = [
            f"{i + 1:>{width}} | {line}" for i, line in enumerate(lines_to_show)
        ]
    else:
        preview_lines = lines_to_show

    result = "\n".join(preview_lines)

    if has_more:
        remaining = len(self._lines) - max_lines
        result += f"\n... ({remaining} more line{'s' if remaining != 1 else ''})"

    return result

reload

reload()

Reload the script content from its associated path.

Raises:

Type Description
ValueError

If script has no associated path

FileNotFoundError

If the file does not exist

IOError

If the file cannot be read

Source code in src/molpy/core/script.py
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
def reload(self) -> None:
    """
    Reload the script content from its associated path.

    Raises:
        ValueError: If script has no associated path
        FileNotFoundError: If the file does not exist
        IOError: If the file cannot be read
    """
    if self.path is None:
        raise ValueError("Cannot reload: script has no associated path")

    if not self.path.exists():
        raise FileNotFoundError(f"Script file not found: {self.path}")

    try:
        content = self.path.read_text(encoding="utf-8")
    except Exception as e:
        raise OSError(f"Failed to read script file {self.path}: {e}") from e

    # Update content
    normalized = content.rstrip("\n")
    self._lines = normalized.splitlines() if normalized else []

rename

rename(new_name)

Rename the script file (keeping the same directory).

Parameters:

Name Type Description Default
new_name str

New file name (with or without extension)

required

Returns:

Type Description
Path

New path where the script was renamed

Raises:

Type Description
ValueError

If script has no associated path

FileNotFoundError

If the original file does not exist

OSError

If the file cannot be renamed

Source code in src/molpy/core/script.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
def rename(self, new_name: str) -> Path:
    """
    Rename the script file (keeping the same directory).

    Args:
        new_name: New file name (with or without extension)

    Returns:
        New path where the script was renamed

    Raises:
        ValueError: If script has no associated path
        FileNotFoundError: If the original file does not exist
        OSError: If the file cannot be renamed
    """
    if self.path is None:
        raise ValueError("Cannot rename: script has no associated path")

    # Preserve extension if new_name doesn't have one
    new_path = self.path.parent / new_name
    if new_path.suffix == "" and self.path.suffix:
        new_path = new_path.with_suffix(self.path.suffix)

    return self.move(new_path)

replace

replace(index, line)

Replace the line at the given index.

Parameters:

Name Type Description Default
index int

0-based index of line to replace

required
line str

New line content

required

Raises:

Type Description
IndexError

If index is out of range

Source code in src/molpy/core/script.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def replace(self, index: int, line: str) -> None:
    """
    Replace the line at the given index.

    Args:
        index: 0-based index of line to replace
        line: New line content

    Raises:
        IndexError: If index is out of range
    """
    if index < 0 or index >= len(self._lines):
        raise IndexError(f"Line index {index} out of range [0, {len(self._lines)})")
    self._lines[index] = line

save

save(path=None)

Save the script to a file.

Parameters:

Name Type Description Default
path str | Path | None

Optional path to save to. If None, uses self.path

None

Returns:

Type Description
Path

Path where the script was saved

Raises:

Type Description
ValueError

If no path is provided and self.path is None

IOError

If the file cannot be written

Source code in src/molpy/core/script.py
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
def save(self, path: str | Path | None = None) -> Path:
    """
    Save the script to a file.

    Args:
        path: Optional path to save to. If None, uses self.path

    Returns:
        Path where the script was saved

    Raises:
        ValueError: If no path is provided and self.path is None
        IOError: If the file cannot be written
    """
    if path is None:
        if self.path is None:
            raise ValueError(
                "No path provided and script has no associated path. "
                "Provide a path argument or set script.path first."
            )
        save_path = self.path
    else:
        save_path = Path(path)

    # Ensure parent directory exists
    save_path.parent.mkdir(parents=True, exist_ok=True)

    try:
        save_path.write_text(self.text, encoding="utf-8")
    except Exception as e:
        raise OSError(f"Failed to write script to {save_path}: {e}") from e

    # Update internal path
    self.path = save_path

    return save_path

Topology

Molecular topology graph using igraph.

Provides graph-based representation of molecular connectivity with automated detection of angles, dihedrals, and impropers.

Topology

Topology(*args, entity_to_idx=None, idx_to_entity=None, **kwargs)

Bases: Graph

Topology graph with bidirectional entity-to-index mapping.

Attributes:

Name Type Description
entity_to_idx dict[Any, int]

Dictionary mapping Entity objects to their vertex indices

idx_to_entity list[Any]

List mapping vertex indices to Entity objects

Initialize Topology graph.

Parameters:

Name Type Description Default
*args

Arguments passed to igraph.Graph.init

()
entity_to_idx dict[Any, int] | None

Optional dictionary mapping entities to indices

None
idx_to_entity list[Any] | None

Optional list mapping indices to entities

None
**kwargs

Keyword arguments passed to igraph.Graph.init

{}
Source code in src/molpy/core/topology.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def __init__(
    self,
    *args,
    entity_to_idx: dict[Any, int] | None = None,
    idx_to_entity: list[Any] | None = None,
    **kwargs,
):
    """Initialize Topology graph.

    Args:
        *args: Arguments passed to igraph.Graph.__init__
        entity_to_idx: Optional dictionary mapping entities to indices
        idx_to_entity: Optional list mapping indices to entities
        **kwargs: Keyword arguments passed to igraph.Graph.__init__
    """
    super().__init__(*args, **kwargs)
    # Initialize bidirectional mapping members
    self.entity_to_idx: dict[Any, int] = (
        entity_to_idx if entity_to_idx is not None else {}
    )
    self.idx_to_entity: list[Any] = (
        idx_to_entity if idx_to_entity is not None else []
    )

angles property

angles

Array of unique angle triplets (N×3), deduplicated.

atoms property

atoms

Array of atom indices.

bonds property

bonds

Array of bond pairs (N×2).

dihedrals property

dihedrals

Array of unique proper dihedral quartets (N×4), deduplicated.

improper property

improper

Array of unique improper dihedral quartets (N×4).

n_angles property

n_angles

Number of unique angles (i-j-k triplets).

n_atoms property

n_atoms

Number of atoms (vertices).

n_bonds property

n_bonds

Number of bonds (edges).

n_dihedrals property

n_dihedrals

Number of unique proper dihedrals (i-j-k-l quartets).

add_angle

add_angle(idx_i, idx_j, idx_k, **props)

Add angle by ensuring bonds i-j and j-k exist.

Source code in src/molpy/core/topology.py
141
142
143
144
145
146
def add_angle(self, idx_i: int, idx_j: int, idx_k: int, **props):
    """Add angle by ensuring bonds i-j and j-k exist."""
    if not self.are_adjacent(idx_i, idx_j):
        self.add_bond(idx_i, idx_j)
    if not self.are_adjacent(idx_j, idx_k):
        self.add_bond(idx_j, idx_k)

add_angles

add_angles(angle_idx)

Add multiple angles from array of triplets.

Source code in src/molpy/core/topology.py
148
149
150
151
152
def add_angles(self, angle_idx: ArrayLike):
    """Add multiple angles from array of triplets."""
    angle_idx = np.array(angle_idx)
    self.add_bonds(angle_idx[:, :2])
    self.add_bonds(angle_idx[:, 1:])

add_atom

add_atom(name, **props)

Add a single atom vertex.

Source code in src/molpy/core/topology.py
116
117
118
def add_atom(self, name: str, **props):
    """Add a single atom vertex."""
    self.add_vertex(name, **props)

add_atoms

add_atoms(n_atoms, **props)

Add multiple atom vertices.

Source code in src/molpy/core/topology.py
120
121
122
def add_atoms(self, n_atoms: int, **props):
    """Add multiple atom vertices."""
    self.add_vertices(n_atoms, props)

add_bond

add_bond(idx_i, idx_j, **props)

Add bond between atoms i and j if not already connected.

Source code in src/molpy/core/topology.py
128
129
130
131
def add_bond(self, idx_i: int, idx_j: int, **props):
    """Add bond between atoms i and j if not already connected."""
    if not self.are_adjacent(idx_i, idx_j):
        self.add_edge(idx_i, idx_j, **props)

add_bonds

add_bonds(bond_idx, **props)

Add multiple bonds from array of pairs.

Source code in src/molpy/core/topology.py
137
138
139
def add_bonds(self, bond_idx: ArrayLike, **props):
    """Add multiple bonds from array of pairs."""
    self.add_edges(bond_idx, **props)

delete_atom

delete_atom(index)

Delete atom(s) by index.

Source code in src/molpy/core/topology.py
124
125
126
def delete_atom(self, index: int | list[int]):
    """Delete atom(s) by index."""
    self.delete_vertrices(index)

delete_bond

delete_bond(index)

Delete bond(s) by index.

Source code in src/molpy/core/topology.py
133
134
135
def delete_bond(self, index: None | int | list[int] | ArrayLike):
    """Delete bond(s) by index."""
    self.delete_edges(index)

union

union(other)

Merge another topology into this one.

Source code in src/molpy/core/topology.py
154
155
156
157
def union(self, other: "Topology"):
    """Merge another topology into this one."""
    self.union(other)
    return self