Skip to content

molpy.builder

Convenience exports for the builder subpackage.

The legacy PolymerBuilder class and bulk builders have been removed in favour of the new declarative API documented in notebooks/reacter_polymerbuilder_integration.ipynb.

AutoConnector

Bases: Connector

BigSMILES-guided automatic port selection.

Strategy: 1. If left has port with role='right' and right has role='left' → use those 2. Else if each side has exactly one unconsumed port → use that pair 3. Else raise AmbiguousPortsError

This implements the common case where: - BigSMILES uses [<] for "left" role and [>] for "right" role - We connect left's "right" port to right's "left" port

select_ports

select_ports(left, right, left_ports, right_ports, ctx)

Select ports using BigSMILES role heuristics.

Source code in src/molpy/builder/polymer/connectors.py
 86
 87
 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
def select_ports(
    self,
    left: Monomer,
    right: Monomer,
    left_ports: Mapping[str, Port],
    right_ports: Mapping[str, Port],
    ctx: ConnectorContext,
) -> tuple[str, str, BondKind | None]:
    """Select ports using BigSMILES role heuristics."""

    # Strategy 1: Try role-based selection (BigSMILES < and >)
    # Case 1a: left has role='right', right has role='left' (normal chain extension)
    left_right_role = [
        name for name, p in left_ports.items() if p.data.get("role") == "right"
    ]
    right_left_role = [
        name for name, p in right_ports.items() if p.data.get("role") == "left"
    ]

    if len(left_right_role) == 1 and len(right_left_role) == 1:
        return (left_right_role[0], right_left_role[0], None)

    # Case 1b: left has role='left', right has role='right' (terminus connection)
    # E.g., HO[*:t] (left terminus) connects to CC[>] (right-directed monomer)
    left_left_role = [
        name for name, p in left_ports.items() if p.data.get("role") == "left"
    ]
    right_right_role = [
        name for name, p in right_ports.items() if p.data.get("role") == "right"
    ]

    if len(left_left_role) == 1 and len(right_right_role) == 1:
        return (left_left_role[0], right_right_role[0], None)

    # Strategy 2: If exactly one port on each side, use those
    if len(left_ports) == 1 and len(right_ports) == 1:
        left_name = next(iter(left_ports.keys()))
        right_name = next(iter(right_ports.keys()))
        return (left_name, right_name, None)

    # Strategy 3: Ambiguous - cannot decide
    raise AmbiguousPortsError(
        f"Cannot auto-select ports between {ctx.get('left_label')} and {ctx.get('right_label')}: "
        f"left has {len(left_ports)} available ports {list(left_ports.keys())}, "
        f"right has {len(right_ports)} available ports {list(right_ports.keys())}. "
        "Use TableConnector or CallbackConnector to specify explicit rules."
    )

BlockRegion

BlockRegion(xmin, xmax, ymin, ymax, zmin, zmax, coord_system='lattice')

Bases: Region

Axis-aligned box region

Define a box region specified by x, y, z ranges.

Parameters

xmin, xmax : float x-direction range [xmin, xmax] ymin, ymax : float y-direction range [ymin, ymax] zmin, zmax : float z-direction range [zmin, zmax] coord_system : CoordSystem, optional Coordinate system, default is "lattice"

Examples

Region in lattice coordinates

region = BlockRegion(0, 10, 0, 10, 0, 10, coord_system="lattice")

Region in Cartesian coordinates

region = BlockRegion(0, 30, 0, 30, 0, 30, coord_system="cartesian")

Initialize box region

Parameters

xmin, xmax : float x-direction range ymin, ymax : float y-direction range zmin, zmax : float z-direction range coord_system : CoordSystem, optional Coordinate system

Source code in src/molpy/builder/crystal.py
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 __init__(
    self,
    xmin: float,
    xmax: float,
    ymin: float,
    ymax: float,
    zmin: float,
    zmax: float,
    coord_system: CoordSystem = "lattice",
) -> None:
    """
    Initialize box region

    Parameters
    ----------
    xmin, xmax : float
        x-direction range
    ymin, ymax : float
        y-direction range
    zmin, zmax : float
        z-direction range
    coord_system : CoordSystem, optional
        Coordinate system
    """
    super().__init__(coord_system)
    self.xmin = xmin
    self.xmax = xmax
    self.ymin = ymin
    self.ymax = ymax
    self.zmin = zmin
    self.zmax = zmax

contains_mask

contains_mask(points)

Check if points are in the box (vectorized)

Parameters

points : np.ndarray Point coordinates array of shape (N, 3)

Returns

np.ndarray Boolean array of shape (N,)

Examples

region = BlockRegion(0, 10, 0, 10, 0, 10) points = np.array([[5, 5, 5], [15, 5, 5]]) mask = region.contains_mask(points) print(mask) # [True, False]

Source code in src/molpy/builder/crystal.py
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
473
474
475
476
477
478
def contains_mask(self, points: np.ndarray) -> np.ndarray:
    """
    Check if points are in the box (vectorized)

    Parameters
    ----------
    points : np.ndarray
        Point coordinates array of shape (N, 3)

    Returns
    -------
    np.ndarray
        Boolean array of shape (N,)

    Examples
    --------
    >>> region = BlockRegion(0, 10, 0, 10, 0, 10)
    >>> points = np.array([[5, 5, 5], [15, 5, 5]])
    >>> mask = region.contains_mask(points)
    >>> print(mask)  # [True, False]
    """
    points = np.asarray(points, dtype=float)
    if points.ndim == 1:
        points = points.reshape(1, -1)

    x = points[:, 0]
    y = points[:, 1]
    z = points[:, 2]

    # Vectorized boundary check
    mask = (
        (self.xmin <= x)
        & (x <= self.xmax)
        & (self.ymin <= y)
        & (y <= self.ymax)
        & (self.zmin <= z)
        & (z <= self.zmax)
    )

    return mask

CallbackConnector

CallbackConnector(fn)

Bases: Connector

User-defined callback for port selection.

Example

def my_selector(left, right, left_ports, right_ports, ctx): # Custom logic here return ("port_out", "port_in", "-")

connector = CallbackConnector(my_selector)

Initialize callback connector.

Parameters:

Name Type Description Default
fn Callable[[Monomer, Monomer, Mapping[str, Port], Mapping[str, Port], ConnectorContext], tuple[str, str] | tuple[str, str, BondKind]]

Callable that takes (left, right, left_ports, right_ports, ctx) and returns (left_port, right_port [, bond_kind])

required
Source code in src/molpy/builder/polymer/connectors.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def __init__(
    self,
    fn: Callable[
        [
            Monomer,
            Monomer,
            Mapping[str, Port],
            Mapping[str, Port],
            ConnectorContext,
        ],
        tuple[str, str] | tuple[str, str, BondKind],
    ],
):
    """
    Initialize callback connector.

    Args:
        fn: Callable that takes (left, right, left_ports, right_ports, ctx)
            and returns (left_port, right_port [, bond_kind])
    """
    self.fn = fn

select_ports

select_ports(left, right, left_ports, right_ports, ctx)

Select ports using user callback.

Source code in src/molpy/builder/polymer/connectors.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def select_ports(
    self,
    left: Monomer,
    right: Monomer,
    left_ports: Mapping[str, Port],
    right_ports: Mapping[str, Port],
    ctx: ConnectorContext,
) -> tuple[str, str, BondKind | None]:
    """Select ports using user callback."""

    result = self.fn(left, right, left_ports, right_ports, ctx)

    if len(result) == 2:
        return (result[0], result[1], None)
    else:
        return (result[0], result[1], result[2])

ChainConnector

ChainConnector(connectors)

Bases: Connector

Try a list of connectors in order; first one that succeeds wins.

Example

connector = ChainConnector([ TableConnector(specific_rules), AutoConnector(), ])

Initialize chain connector.

Parameters:

Name Type Description Default
connectors Iterable[Connector]

List of connectors to try in order

required
Source code in src/molpy/builder/polymer/connectors.py
259
260
261
262
263
264
265
266
def __init__(self, connectors: Iterable[Connector]):
    """
    Initialize chain connector.

    Args:
        connectors: List of connectors to try in order
    """
    self.connectors = list(connectors)

select_ports

select_ports(left, right, left_ports, right_ports, ctx)

Try connectors in order until one succeeds.

Source code in src/molpy/builder/polymer/connectors.py
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
def select_ports(
    self,
    left: Monomer,
    right: Monomer,
    left_ports: Mapping[str, Port],
    right_ports: Mapping[str, Port],
    ctx: ConnectorContext,
) -> tuple[str, str, BondKind | None]:
    """Try connectors in order until one succeeds."""

    errors = []
    for connector in self.connectors:
        try:
            return connector.select_ports(left, right, left_ports, right_ports, ctx)
        except (
            AmbiguousPortsError,
            MissingConnectorRule,
            NoCompatiblePortsError,
        ) as e:
            errors.append(f"{type(connector).__name__}: {e}")
            continue

    # All connectors failed
    raise AmbiguousPortsError(
        f"All connectors failed for ({ctx.get('left_label')}, {ctx.get('right_label')}): "
        f"{'; '.join(errors)}"
    )

Connector

Abstract base for port selection between two adjacent monomers.

This is topology-only: connectors decide WHICH ports to connect, not HOW to position them geometrically.

select_ports

select_ports(left, right, left_ports, right_ports, ctx)

Select which ports to connect between left and right monomers.

Parameters:

Name Type Description Default
left Monomer

Left monomer in the sequence

required
right Monomer

Right monomer in the sequence

required
left_ports Mapping[str, Port]

Available (unconsumed) ports on left monomer

required
right_ports Mapping[str, Port]

Available (unconsumed) ports on right monomer

required
ctx ConnectorContext

Shared context with step info, sequence, etc.

required

Returns:

Type Description
tuple[str, str, BondKind | None]

Tuple of (left_port_name, right_port_name, optional_bond_kind_override)

Raises:

Type Description
AmbiguousPortsError

Cannot uniquely determine ports

NoCompatiblePortsError

No valid port pair found

MissingConnectorRule

Required rule not found (TableConnector)

Source code in src/molpy/builder/polymer/connectors.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def select_ports(
    self,
    left: Monomer,
    right: Monomer,
    left_ports: Mapping[str, Port],  # unconsumed ports
    right_ports: Mapping[str, Port],  # unconsumed ports
    ctx: ConnectorContext,
) -> tuple[str, str, BondKind | None]:
    """
    Select which ports to connect between left and right monomers.

    Args:
        left: Left monomer in the sequence
        right: Right monomer in the sequence
        left_ports: Available (unconsumed) ports on left monomer
        right_ports: Available (unconsumed) ports on right monomer
        ctx: Shared context with step info, sequence, etc.

    Returns:
        Tuple of (left_port_name, right_port_name, optional_bond_kind_override)

    Raises:
        AmbiguousPortsError: Cannot uniquely determine ports
        NoCompatiblePortsError: No valid port pair found
        MissingConnectorRule: Required rule not found (TableConnector)
    """
    raise NotImplementedError("Subclasses must implement select_ports()")

ConnectorContext

Bases: dict[str, Any]

Shared context passed to connectors during linear build.

Contains information like: - step: int (current connection step index) - sequence: str (full sequence being built) - left_label: str (label of left monomer) - right_label: str (label of right monomer) - audit: list (accumulated connection records)

CrystalBuilder

CrystalBuilder(lattice)

Crystal structure builder

Efficiently generate crystal structures using NumPy vectorized operations. Supports tiling lattices and creating atoms in specified regions.

Parameters

lattice : Lattice Lattice definition to use

Examples

Create a simple FCC structure

lat = Lattice.cubic_fcc(a=3.52, species="Ni") region = BlockRegion(0, 10, 0, 10, 0, 10, coord_system="lattice") builder = CrystalBuilder(lat) structure = builder.build_block(region) print(len(structure.atoms))

Initialize crystal builder

Parameters

lattice : Lattice Lattice definition

Source code in src/molpy/builder/crystal.py
503
504
505
506
507
508
509
510
511
512
def __init__(self, lattice: Lattice) -> None:
    """
    Initialize crystal builder

    Parameters
    ----------
    lattice : Lattice
        Lattice definition
    """
    self.lattice = lattice

build_block

build_block(region, *, i_range=None, j_range=None, k_range=None)

Build crystal structure within a box region

This method efficiently generates crystal structures using vectorized operations: 1. Determine cell index ranges to tile 2. Use NumPy meshgrid and broadcasting to generate all atom positions 3. Apply region filtering 4. Create and return Atomistic structure

Parameters

region : BlockRegion Box defining the region for atom generation i_range, j_range, k_range : range | None, optional Explicitly specify cell index ranges. If not provided: - For "lattice" coordinate system: inferred from region boundaries - For "cartesian" coordinate system: must be provided, otherwise raises error

Returns

Atomistic Generated crystal structure containing atoms and box information

Raises

ValueError If coord_system == "cartesian" and explicit ranges are not provided

Examples

Using lattice coordinates (auto-infer ranges)

lat = Lattice.cubic_sc(a=2.0, species="Cu") region = BlockRegion(0, 10, 0, 10, 0, 10, coord_system="lattice") builder = CrystalBuilder(lat) structure = builder.build_block(region)

Using explicit ranges

structure = builder.build_block( ... region, ... i_range=range(0, 5), ... j_range=range(0, 5), ... k_range=range(0, 5) ... )

Cartesian coordinates (must provide ranges)

region_cart = BlockRegion(0, 20, 0, 20, 0, 20, coord_system="cartesian") structure = builder.build_block( ... region_cart, ... i_range=range(0, 10), ... j_range=range(0, 10), ... k_range=range(0, 10) ... )

Notes
  • This method uses no Python loops, fully based on NumPy vectorized operations
  • Generated structure contains:
  • Atom positions (Cartesian coordinates)
  • Atom species
  • Box information (lattice vectors)
  • For empty basis (no basis sites), returns empty Atomistic structure
Source code in src/molpy/builder/crystal.py
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
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
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
669
670
671
672
673
674
675
676
677
678
679
680
681
def build_block(
    self,
    region: BlockRegion,
    *,
    i_range: range | None = None,
    j_range: range | None = None,
    k_range: range | None = None,
) -> Atomistic:
    """
    Build crystal structure within a box region

    This method efficiently generates crystal structures using vectorized operations:
    1. Determine cell index ranges to tile
    2. Use NumPy meshgrid and broadcasting to generate all atom positions
    3. Apply region filtering
    4. Create and return Atomistic structure

    Parameters
    ----------
    region : BlockRegion
        Box defining the region for atom generation
    i_range, j_range, k_range : range | None, optional
        Explicitly specify cell index ranges. If not provided:
        - For "lattice" coordinate system: inferred from region boundaries
        - For "cartesian" coordinate system: must be provided, otherwise raises error

    Returns
    -------
    Atomistic
        Generated crystal structure containing atoms and box information

    Raises
    ------
    ValueError
        If coord_system == "cartesian" and explicit ranges are not provided

    Examples
    --------
    >>> # Using lattice coordinates (auto-infer ranges)
    >>> lat = Lattice.cubic_sc(a=2.0, species="Cu")
    >>> region = BlockRegion(0, 10, 0, 10, 0, 10, coord_system="lattice")
    >>> builder = CrystalBuilder(lat)
    >>> structure = builder.build_block(region)
    >>>
    >>> # Using explicit ranges
    >>> structure = builder.build_block(
    ...     region,
    ...     i_range=range(0, 5),
    ...     j_range=range(0, 5),
    ...     k_range=range(0, 5)
    ... )
    >>>
    >>> # Cartesian coordinates (must provide ranges)
    >>> region_cart = BlockRegion(0, 20, 0, 20, 0, 20, coord_system="cartesian")
    >>> structure = builder.build_block(
    ...     region_cart,
    ...     i_range=range(0, 10),
    ...     j_range=range(0, 10),
    ...     k_range=range(0, 10)
    ... )

    Notes
    -----
    - This method uses no Python loops, fully based on NumPy vectorized operations
    - Generated structure contains:
      - Atom positions (Cartesian coordinates)
      - Atom species
      - Box information (lattice vectors)
    - For empty basis (no basis sites), returns empty Atomistic structure
    """
    basis_sites = self.lattice.basis

    # If no basis sites, return empty structure
    if len(basis_sites) == 0:
        structure = Atomistic()
        structure["box"] = Box(matrix=self.lattice.cell)
        return structure

    # Step 1: Determine cell index ranges
    if i_range is not None and j_range is not None and k_range is not None:
        # Use explicitly provided ranges
        i_arr = np.array(list(i_range))
        j_arr = np.array(list(j_range))
        k_arr = np.array(list(k_range))
    elif region.coord_system == "lattice":
        # Infer ranges from lattice coordinate region
        i_min = int(np.floor(region.xmin))
        i_max = int(np.ceil(region.xmax))
        j_min = int(np.floor(region.ymin))
        j_max = int(np.ceil(region.ymax))
        k_min = int(np.floor(region.zmin))
        k_max = int(np.ceil(region.zmax))

        i_arr = np.arange(i_min, i_max)
        j_arr = np.arange(j_min, j_max)
        k_arr = np.arange(k_min, k_max)
    else:
        # Cartesian coordinate system requires explicit ranges
        raise ValueError(
            "For cartesian coordinate system, you must provide explicit "
            "i_range, j_range, and k_range parameters."
        )

    # Step 2: Build cell index grid using NumPy
    I, J, K = np.meshgrid(i_arr, j_arr, k_arr, indexing="ij")
    cells = np.stack([I, J, K], axis=-1).reshape(-1, 3)  # (Nc, 3)

    # Step 3: Combine cells and basis via broadcasting (no Python loops)
    basis_fracs = np.array(
        [list(s.frac) for s in basis_sites], dtype=float
    )  # (Nb, 3)

    # Broadcasting: cells[:, None, :] is (Nc, 1, 3), basis_fracs[None, :, :] is (1, Nb, 3)
    # Result is (Nc, Nb, 3)
    frac_lattice = cells[:, None, :] + basis_fracs[None, :, :]  # (Nc, Nb, 3)
    frac_lattice = frac_lattice.reshape(-1, 3)  # (N, 3) where N = Nc * Nb

    # Step 4: Region filtering + coordinate system conversion
    if region.coord_system == "lattice":
        # Filter in lattice coordinates
        mask = region.contains_mask(frac_lattice)
        frac_selected = frac_lattice[mask]

        # Convert to Cartesian coordinates
        cart_selected = self.lattice.frac_to_cart(frac_selected)
    else:
        # coord_system == "cartesian"
        # First convert all coordinates to Cartesian
        cart_all = self.lattice.frac_to_cart(frac_lattice)
        mask = region.contains_mask(cart_all)
        cart_selected = cart_all[mask]
        frac_selected = frac_lattice[mask]

    # Step 5: Get species and site info via vectorization
    Nc = cells.shape[0]
    species_array = np.array(
        [s.species for s in basis_sites], dtype=object
    )  # (Nb,)
    site_array = np.array(basis_sites, dtype=object)  # (Nb,)

    # Tile to match each cell
    species_tiled = np.tile(species_array, Nc)  # (N,)
    site_tiled = np.tile(site_array, Nc)  # (N,)

    # Apply filtering
    species_selected = species_tiled[mask]  # (M,)
    site_selected = site_tiled[mask]  # (M,)

    # Step 6: Build project's Atomistic structure type
    structure = Atomistic()

    # Add atoms
    for i in range(len(cart_selected)):
        xyz = cart_selected[i]
        species = species_selected[i]
        site = site_selected[i]

        structure.def_atom(
            xyz=xyz.tolist(),
            symbol=species,
            charge=site.charge,
            label=site.label,
        )

    # Set box (using lattice matrix)
    structure["box"] = Box(matrix=self.lattice.cell)

    return structure

Lattice

Lattice(a1, a2, a3, basis)

Bravais lattice with basis sites.

This class defines a crystal lattice structure, including lattice vectors and basis sites. Lattice vectors define the shape and size of the unit cell, while basis sites define the positions of atoms within the cell (in fractional coordinates).

Parameters

a1, a2, a3 : np.ndarray Lattice vectors, each is a NumPy array of shape (3,) basis : list[Site] List of basis sites in fractional coordinates

Attributes

a1, a2, a3 : np.ndarray Lattice vectors basis : list[Site] List of basis sites

Examples

Create simple cubic lattice

lat = Lattice.cubic_sc(a=2.0, species="Cu")

Create face-centered cubic lattice

lat = Lattice.cubic_fcc(a=3.52, species="Ni")

Create rocksalt structure

lat = Lattice.rocksalt(a=5.64, species_a="Na", species_b="Cl")

Initialize lattice

Parameters

a1, a2, a3 : np.ndarray Lattice vectors of shape (3,) basis : list[Site] List of basis sites

Source code in src/molpy/builder/crystal.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def __init__(
    self, a1: np.ndarray, a2: np.ndarray, a3: np.ndarray, basis: list[Site]
) -> None:
    """
    Initialize lattice

    Parameters
    ----------
    a1, a2, a3 : np.ndarray
        Lattice vectors of shape (3,)
    basis : list[Site]
        List of basis sites
    """
    self.a1 = np.asarray(a1, dtype=float)
    self.a2 = np.asarray(a2, dtype=float)
    self.a3 = np.asarray(a3, dtype=float)
    self.basis = list(basis)

    # Validate shape
    assert self.a1.shape == (3,), "a1 must be a 1D array of length 3"
    assert self.a2.shape == (3,), "a2 must be a 1D array of length 3"
    assert self.a3.shape == (3,), "a3 must be a 1D array of length 3"

cell property

cell

Return 3×3 cell matrix with lattice vectors as rows

Returns

np.ndarray Matrix of shape (3, 3), each row is a lattice vector [a1; a2; a3]

add_site

add_site(site)

Add a basis site

Parameters

site : Site Basis site to add

Source code in src/molpy/builder/crystal.py
132
133
134
135
136
137
138
139
140
141
def add_site(self, site: Site) -> None:
    """
    Add a basis site

    Parameters
    ----------
    site : Site
        Basis site to add
    """
    self.basis.append(site)

cubic_bcc classmethod

cubic_bcc(a, species)

Create body-centered cubic (Body-Centered Cubic, BCC) lattice

Body-centered cubic lattice has two atoms per unit cell: one at corner and one at body center.

Parameters

a : float Lattice constant (in Å) species : str Atomic species (e.g., "Fe", "W")

Returns

Lattice Body-centered cubic lattice

Examples

lat = Lattice.cubic_bcc(a=3.0, species="Fe") print(len(lat.basis)) # 2

Source code in src/molpy/builder/crystal.py
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
@classmethod
def cubic_bcc(cls, a: float, species: str) -> Lattice:
    """
    Create body-centered cubic (Body-Centered Cubic, BCC) lattice

    Body-centered cubic lattice has two atoms per unit cell: one at corner and one at body center.

    Parameters
    ----------
    a : float
        Lattice constant (in Å)
    species : str
        Atomic species (e.g., "Fe", "W")

    Returns
    -------
    Lattice
        Body-centered cubic lattice

    Examples
    --------
    >>> lat = Lattice.cubic_bcc(a=3.0, species="Fe")
    >>> print(len(lat.basis))  # 2
    """
    a1 = np.array([a, 0.0, 0.0])
    a2 = np.array([0.0, a, 0.0])
    a3 = np.array([0.0, 0.0, a])

    basis = [
        Site(label="A", species=species, frac=(0.0, 0.0, 0.0)),
        Site(label="B", species=species, frac=(0.5, 0.5, 0.5)),
    ]

    return cls(a1=a1, a2=a2, a3=a3, basis=basis)

cubic_fcc classmethod

cubic_fcc(a, species)

Create face-centered cubic (Face-Centered Cubic, FCC) lattice

Face-centered cubic lattice has four atoms per unit cell: one at corner and one at each face center.

Parameters

a : float Lattice constant (in Å) species : str Atomic species (e.g., "Ni", "Cu", "Al")

Returns

Lattice Face-centered cubic lattice

Examples

lat = Lattice.cubic_fcc(a=3.52, species="Ni") print(len(lat.basis)) # 4

Source code in src/molpy/builder/crystal.py
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
@classmethod
def cubic_fcc(cls, a: float, species: str) -> Lattice:
    """
    Create face-centered cubic (Face-Centered Cubic, FCC) lattice

    Face-centered cubic lattice has four atoms per unit cell: one at corner and one at each face center.

    Parameters
    ----------
    a : float
        Lattice constant (in Å)
    species : str
        Atomic species (e.g., "Ni", "Cu", "Al")

    Returns
    -------
    Lattice
        Face-centered cubic lattice

    Examples
    --------
    >>> lat = Lattice.cubic_fcc(a=3.52, species="Ni")
    >>> print(len(lat.basis))  # 4
    """
    a1 = np.array([a, 0.0, 0.0])
    a2 = np.array([0.0, a, 0.0])
    a3 = np.array([0.0, 0.0, a])

    basis = [
        Site(label="A", species=species, frac=(0.0, 0.0, 0.0)),
        Site(label="B", species=species, frac=(0.5, 0.5, 0.0)),
        Site(label="C", species=species, frac=(0.5, 0.0, 0.5)),
        Site(label="D", species=species, frac=(0.0, 0.5, 0.5)),
    ]

    return cls(a1=a1, a2=a2, a3=a3, basis=basis)

cubic_sc classmethod

cubic_sc(a, species)

Create simple cubic (Simple Cubic, SC) lattice

Simple cubic lattice is the simplest lattice type with one atom per unit cell.

Parameters

a : float Lattice constant (in Å) species : str Atomic species (e.g., "Cu", "Fe")

Returns

Lattice Simple cubic lattice

Examples

lat = Lattice.cubic_sc(a=2.0, species="Cu") print(len(lat.basis)) # 1

Source code in src/molpy/builder/crystal.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
@classmethod
def cubic_sc(cls, a: float, species: str) -> Lattice:
    """
    Create simple cubic (Simple Cubic, SC) lattice

    Simple cubic lattice is the simplest lattice type with one atom per unit cell.

    Parameters
    ----------
    a : float
        Lattice constant (in Å)
    species : str
        Atomic species (e.g., "Cu", "Fe")

    Returns
    -------
    Lattice
        Simple cubic lattice

    Examples
    --------
    >>> lat = Lattice.cubic_sc(a=2.0, species="Cu")
    >>> print(len(lat.basis))  # 1
    """
    a1 = np.array([a, 0.0, 0.0])
    a2 = np.array([0.0, a, 0.0])
    a3 = np.array([0.0, 0.0, a])

    basis = [Site(label="A", species=species, frac=(0.0, 0.0, 0.0))]

    return cls(a1=a1, a2=a2, a3=a3, basis=basis)

frac_to_cart

frac_to_cart(frac)

Convert fractional coordinates to Cartesian coordinates

Fractional coordinates (u, v, w) represent position relative to lattice vectors: cart = ua1 + va2 + w*a3 = frac @ cell

Parameters

frac : np.ndarray Fractional coordinates of shape (N, 3) or (3,), containing (u, v, w) values

Returns

np.ndarray Cartesian coordinates, same shape as input

Examples

lat = Lattice.cubic_sc(a=2.0, species="Cu") frac = np.array([0.5, 0.5, 0.5]) cart = lat.frac_to_cart(frac) print(cart) # [1.0, 1.0, 1.0]

Source code in src/molpy/builder/crystal.py
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
def frac_to_cart(self, frac: np.ndarray) -> np.ndarray:
    """
    Convert fractional coordinates to Cartesian coordinates

    Fractional coordinates (u, v, w) represent position relative to lattice vectors:
    cart = u*a1 + v*a2 + w*a3 = frac @ cell

    Parameters
    ----------
    frac : np.ndarray
        Fractional coordinates of shape (N, 3) or (3,), containing (u, v, w) values

    Returns
    -------
    np.ndarray
        Cartesian coordinates, same shape as input

    Examples
    --------
    >>> lat = Lattice.cubic_sc(a=2.0, species="Cu")
    >>> frac = np.array([0.5, 0.5, 0.5])
    >>> cart = lat.frac_to_cart(frac)
    >>> print(cart)  # [1.0, 1.0, 1.0]
    """
    frac = np.asarray(frac, dtype=float)
    # Use single matrix multiplication: cart = frac @ cell
    return frac @ self.cell

rocksalt classmethod

rocksalt(a, species_a, species_b)

Create rocksalt (NaCl) structure

Rocksalt structure consists of two interpenetrating FCC sublattices. Each unit cell contains 4 A atoms and 4 B atoms.

Parameters

a : float Lattice constant (in Å) species_a : str First atomic species (e.g., "Na") species_b : str Second atomic species (e.g., "Cl")

Returns

Lattice Rocksalt structure lattice

Examples

lat = Lattice.rocksalt(a=5.64, species_a="Na", species_b="Cl") print(len(lat.basis)) # 8

Source code in src/molpy/builder/crystal.py
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
324
@classmethod
def rocksalt(cls, a: float, species_a: str, species_b: str) -> Lattice:
    """
    Create rocksalt (NaCl) structure

    Rocksalt structure consists of two interpenetrating FCC sublattices.
    Each unit cell contains 4 A atoms and 4 B atoms.

    Parameters
    ----------
    a : float
        Lattice constant (in Å)
    species_a : str
        First atomic species (e.g., "Na")
    species_b : str
        Second atomic species (e.g., "Cl")

    Returns
    -------
    Lattice
        Rocksalt structure lattice

    Examples
    --------
    >>> lat = Lattice.rocksalt(a=5.64, species_a="Na", species_b="Cl")
    >>> print(len(lat.basis))  # 8
    """
    a1 = np.array([a, 0.0, 0.0])
    a2 = np.array([0.0, a, 0.0])
    a3 = np.array([0.0, 0.0, a])

    # FCC sites for species_a
    basis_a = [
        Site(label="A1", species=species_a, frac=(0.0, 0.0, 0.0)),
        Site(label="A2", species=species_a, frac=(0.5, 0.5, 0.0)),
        Site(label="A3", species=species_a, frac=(0.5, 0.0, 0.5)),
        Site(label="A4", species=species_a, frac=(0.0, 0.5, 0.5)),
    ]

    # Offset FCC sites for species_b (shifted by 0.5 along x direction)
    basis_b = [
        Site(label="B1", species=species_b, frac=(0.5, 0.0, 0.0)),
        Site(label="B2", species=species_b, frac=(0.0, 0.5, 0.0)),
        Site(label="B3", species=species_b, frac=(0.0, 0.0, 0.5)),
        Site(label="B4", species=species_b, frac=(0.5, 0.5, 0.5)),
    ]

    basis = basis_a + basis_b

    return cls(a1=a1, a2=a2, a3=a3, basis=basis)

ReacterConnector

ReacterConnector(default, port_map, overrides=None)

Bases: Connector

Connector that uses chemical reactions (Reacter) for polymer assembly.

This connector integrates port selection and chemical reaction execution. It manages multiple Reacter instances and port mapping strategies for different monomer pairs.

Port Selection Strategy: Port selection is handled via a port_strategy which must be one of: 1. Callable: Custom function (left, right, left_ports, right_ports, ctx) -> (port_L, port_R) 2. Dict: Explicit mapping {('A','B'): ('1','2'), ...}

There is NO 'auto' mode - port selection must be explicit via port_map.

Attributes:

Name Type Description
default

Default Reacter for most connections

overrides

Dict mapping (left_type, right_type) -> specialized Reacter

port_map

Dict mapping (left_type, right_type) -> (port_L, port_R)

Example

from molpy.reacter import Reacter from molpy.reacter.selectors import port_anchor_selector, remove_one_H from molpy.reacter.transformers import make_single_bond

default_reacter = Reacter( ... name="C-C_coupling", ... anchor_left=port_anchor_selector, ... anchor_right=port_anchor_selector, ... leaving_left=remove_one_H, ... leaving_right=remove_one_H, ... bond_maker=make_single_bond, ... )

Explicit port mapping for all monomer pairs

connector = ReacterConnector( ... default=default_reacter, ... port_map={ ... ('A', 'B'): ('port_1', 'port_2'), ... ('B', 'C'): ('port_3', 'port_4'), ... }, ... overrides={('B', 'C'): special_reacter}, ... )

Initialize ReacterConnector.

Parameters:

Name Type Description Default
default Reacter

Default Reacter for most connections

required
port_map dict[tuple[str, str], tuple[str, str]]

Mapping from (left_type, right_type) to (port_L, port_R)

required
overrides dict[tuple[str, str], Reacter] | None

Optional mapping from (left_type, right_type) to specialized Reacter instances

None

Raises:

Type Description
TypeError

If port_map is not a dict

Source code in src/molpy/builder/polymer/connectors.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def __init__(
    self,
    default: "Reacter",
    port_map: dict[tuple[str, str], tuple[str, str]],
    overrides: dict[tuple[str, str], "Reacter"] | None = None,
):
    """
    Initialize ReacterConnector.

    Args:
        default: Default Reacter for most connections
        port_map: Mapping from (left_type, right_type) to (port_L, port_R)
        overrides: Optional mapping from (left_type, right_type) to
            specialized Reacter instances

    Raises:
        TypeError: If port_map is not a dict
    """
    if not isinstance(port_map, dict):
        raise TypeError(f"port_map must be dict, got {type(port_map).__name__}")

    self.default = default
    self.overrides = overrides or {}
    self.port_map = port_map
    self._history: list = []  # List of ProductSet objects

connect

connect(left, right, left_type, right_type, port_L, port_R)

Execute chemical reaction between two monomers.

This method performs the full chemical reaction including: 1. Selecting appropriate reacter based on monomer types 2. Executing reaction (merging, bond making, removing leaving groups) 3. Computing new topology (angles, dihedrals) 4. Collecting metadata for retypification

Parameters:

Name Type Description Default
left Monomer

Left monomer

required
right Monomer

Right monomer

required
left_type str

Type label of left monomer

required
right_type str

Type label of right monomer

required
port_L str

Port name on left monomer

required
port_R str

Port name on right monomer

required

Returns:

Type Description
Atomistic

Tuple of (assembly, metadata) where:

dict[str, Any]
  • assembly: Atomistic product of reaction
tuple[Atomistic, dict[str, Any]]
  • metadata: Dict containing:
  • port_L, port_R: used ports
  • reaction_name: name of the reacter
  • new_bonds, new_angles, new_dihedrals: newly created topology
  • modified_atoms: atoms whose types may have changed
  • needs_retypification: whether retypification is needed
Source code in src/molpy/builder/polymer/connectors.py
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
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
def connect(
    self,
    left: Monomer,
    right: Monomer,
    left_type: str,
    right_type: str,
    port_L: str,
    port_R: str,
) -> tuple["Atomistic", dict[str, Any]]:
    """
    Execute chemical reaction between two monomers.

    This method performs the full chemical reaction including:
    1. Selecting appropriate reacter based on monomer types
    2. Executing reaction (merging, bond making, removing leaving groups)
    3. Computing new topology (angles, dihedrals)
    4. Collecting metadata for retypification

    Args:
        left: Left monomer
        right: Right monomer
        left_type: Type label of left monomer
        right_type: Type label of right monomer
        port_L: Port name on left monomer
        port_R: Port name on right monomer

    Returns:
        Tuple of (assembly, metadata) where:
        - assembly: Atomistic product of reaction
        - metadata: Dict containing:
            - port_L, port_R: used ports
            - reaction_name: name of the reacter
            - new_bonds, new_angles, new_dihedrals: newly created topology
            - modified_atoms: atoms whose types may have changed
            - needs_retypification: whether retypification is needed
    """
    from molpy.reacter.base import ProductSet

    # Select reacter
    reacter = self.get_reacter(left_type, right_type)

    # Execute reaction
    product_set: ProductSet = reacter.run(
        left,
        right,
        port_L=port_L,
        port_R=port_R,
        compute_topology=True,
    )

    # Store in history
    self._history.append(product_set)

    # Extract metadata
    metadata = {
        "port_L": port_L,
        "port_R": port_R,
        "reaction_name": reacter.name,
        "new_bonds": product_set.notes.get("new_bonds", []),
        "new_angles": product_set.notes.get("new_angles", []),
        "new_dihedrals": product_set.notes.get("new_dihedrals", []),
        "modified_atoms": product_set.notes.get("modified_atoms", set()),
        "needs_retypification": product_set.notes.get(
            "needs_retypification", False
        ),
        "entity_maps": product_set.notes.get(
            "entity_maps", []
        ),  # For port remapping
    }

    return product_set.product, metadata

get_all_modified_atoms

get_all_modified_atoms()

Get all atoms modified across all reactions.

Source code in src/molpy/builder/polymer/connectors.py
507
508
509
510
511
512
def get_all_modified_atoms(self) -> set:
    """Get all atoms modified across all reactions."""
    all_atoms = set()
    for product in self._history:
        all_atoms.update(product.notes.get("modified_atoms", set()))
    return all_atoms

get_history

get_history()

Get all reaction history (list of ProductSet).

Source code in src/molpy/builder/polymer/connectors.py
503
504
505
def get_history(self) -> list:
    """Get all reaction history (list of ProductSet)."""
    return self._history

get_reacter

get_reacter(left_type, right_type)

Get appropriate reacter for a monomer pair.

Parameters:

Name Type Description Default
left_type str

Type label of left monomer (e.g., 'A', 'B')

required
right_type str

Type label of right monomer

required

Returns:

Type Description
Reacter

The appropriate Reacter (override if exists, else default)

Source code in src/molpy/builder/polymer/connectors.py
368
369
370
371
372
373
374
375
376
377
378
379
380
def get_reacter(self, left_type: str, right_type: str) -> "Reacter":
    """
    Get appropriate reacter for a monomer pair.

    Args:
        left_type: Type label of left monomer (e.g., 'A', 'B')
        right_type: Type label of right monomer

    Returns:
        The appropriate Reacter (override if exists, else default)
    """
    key = (left_type, right_type)
    return self.overrides.get(key, self.default)

needs_retypification

needs_retypification()

Check if any reactions require retypification.

Source code in src/molpy/builder/polymer/connectors.py
514
515
516
def needs_retypification(self) -> bool:
    """Check if any reactions require retypification."""
    return any(p.notes.get("needs_retypification", False) for p in self._history)

select_ports

select_ports(left, right, left_ports, right_ports, ctx)

Select ports using the configured port_map.

Parameters:

Name Type Description Default
left Monomer

Left monomer

required
right Monomer

Right monomer

required
left_ports Mapping[str, Port]

Available ports on left

required
right_ports Mapping[str, Port]

Available ports on right

required
ctx ConnectorContext

Connector context with monomer type information

required

Returns:

Type Description
tuple[str, str, BondKind | None]

Tuple of (port_L, port_R, None)

Raises:

Type Description
ValueError

If port mapping not found or ports invalid

Source code in src/molpy/builder/polymer/connectors.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
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
def select_ports(
    self,
    left: Monomer,
    right: Monomer,
    left_ports: Mapping[str, Port],
    right_ports: Mapping[str, Port],
    ctx: ConnectorContext,
) -> tuple[str, str, BondKind | None]:
    """
    Select ports using the configured port_map.

    Args:
        left: Left monomer
        right: Right monomer
        left_ports: Available ports on left
        right_ports: Available ports on right
        ctx: Connector context with monomer type information

    Returns:
        Tuple of (port_L, port_R, None)

    Raises:
        ValueError: If port mapping not found or ports invalid
    """
    # Get monomer types from context
    left_type = ctx.get("left_label", "")
    right_type = ctx.get("right_label", "")

    # Look up explicit mapping
    key = (left_type, right_type)
    if key not in self.port_map:
        raise ValueError(
            f"No port mapping defined for ({left_type}, {right_type}). "
            f"Available mappings: {list(self.port_map.keys())}"
        )
    port_L, port_R = self.port_map[key]

    # Validate ports exist
    if port_L not in left_ports:
        raise ValueError(
            f"Selected port '{port_L}' not found in left monomer ({left_type})"
        )
    if port_R not in right_ports:
        raise ValueError(
            f"Selected port '{port_R}' not found in right monomer ({right_type})"
        )

    return port_L, port_R, None  # bond_kind determined by reacter

Region

Region(coord_system='lattice')

Bases: ABC

Abstract geometric region class

Define a spatial region that can be represented in lattice or Cartesian coordinates.

Parameters

coord_system : CoordSystem Coordinate system, "lattice" or "cartesian" - "lattice": point coordinates in lattice units - "cartesian": point coordinates in Cartesian coordinates (Å)

Notes

Subclasses must implement contains_mask method using NumPy vectorized operations to efficiently check if multiple points are in the region.

Initialize region

Parameters

coord_system : CoordSystem, optional Coordinate system, default is "lattice"

Source code in src/molpy/builder/crystal.py
346
347
348
349
350
351
352
353
354
355
def __init__(self, coord_system: CoordSystem = "lattice") -> None:
    """
    Initialize region

    Parameters
    ----------
    coord_system : CoordSystem, optional
        Coordinate system, default is "lattice"
    """
    self.coord_system = coord_system

contains_mask abstractmethod

contains_mask(points)

Check if points are in the region (vectorized)

Parameters

points : np.ndarray Point coordinates array of shape (N, 3)

Returns

np.ndarray Boolean array of shape (N,), True indicates point is in region

Notes
  • If coord_system == "lattice": points are in lattice units
  • If coord_system == "cartesian": points are in Cartesian coordinates
  • Must use vectorized operations, no Python loops
Source code in src/molpy/builder/crystal.py
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
@abstractmethod
def contains_mask(self, points: np.ndarray) -> np.ndarray:
    """
    Check if points are in the region (vectorized)

    Parameters
    ----------
    points : np.ndarray
        Point coordinates array of shape (N, 3)

    Returns
    -------
    np.ndarray
        Boolean array of shape (N,), True indicates point is in region

    Notes
    -----
    - If coord_system == "lattice": points are in lattice units
    - If coord_system == "cartesian": points are in Cartesian coordinates
    - Must use vectorized operations, no Python loops
    """
    pass

Site dataclass

Site(label, species, frac, charge=0.0, meta=None)

Lattice basis site in fractional coordinates.

Attributes

label : str Site identifier or name (e.g., "A", "B1") species : str Chemical species or type name (e.g., "Ni", "Na", "Cl") frac : tuple[float, float, float] Fractional coordinates (u, v, w) relative to the Bravais cell, typically in [0, 1) charge : float, optional Charge, default is 0.0 meta : dict[str, Any] | None, optional Optional metadata dictionary

Examples

site = Site(label="A", species="Cu", frac=(0.0, 0.0, 0.0)) site_charged = Site(label="Na", species="Na", frac=(0.0, 0.0, 0.0), charge=1.0)

TableConnector

TableConnector(rules, fallback=None)

Bases: Connector

Rule-based port selection using a lookup table.

Maps (left_label, right_label) → (left_port, right_port [, bond_kind])

Example

rules = { ("A", "B"): ("1", "2"), ("B", "A"): ("3", "1", "="), # with bond kind override ("T", "A"): ("t", "1"), } connector = TableConnector(rules, fallback=AutoConnector())

Initialize table connector.

Parameters:

Name Type Description Default
rules Mapping[tuple[str, str], tuple[str, str] | tuple[str, str, BondKind]]

Mapping from (left_label, right_label) to port specifications

required
fallback Connector | None

Optional connector to try if pair not in rules

None
Source code in src/molpy/builder/polymer/connectors.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def __init__(
    self,
    rules: Mapping[tuple[str, str], tuple[str, str] | tuple[str, str, BondKind]],
    fallback: Connector | None = None,
):
    """
    Initialize table connector.

    Args:
        rules: Mapping from (left_label, right_label) to port specifications
        fallback: Optional connector to try if pair not in rules
    """
    self.rules = dict(rules)  # Convert to dict for internal use
    self.fallback = fallback

select_ports

select_ports(left, right, left_ports, right_ports, ctx)

Select ports using table lookup.

Source code in src/molpy/builder/polymer/connectors.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
def select_ports(
    self,
    left: Monomer,
    right: Monomer,
    left_ports: Mapping[str, Port],
    right_ports: Mapping[str, Port],
    ctx: ConnectorContext,
) -> tuple[str, str, BondKind | None]:
    """Select ports using table lookup."""

    left_label = ctx.get("left_label", "")
    right_label = ctx.get("right_label", "")
    key = (left_label, right_label)

    if key in self.rules:
        rule = self.rules[key]
        if len(rule) == 2:
            return (rule[0], rule[1], None)
        else:
            return (rule[0], rule[1], rule[2])

    # Try fallback if available
    if self.fallback is not None:
        return self.fallback.select_ports(left, right, left_ports, right_ports, ctx)

    # No rule and no fallback
    raise MissingConnectorRule(
        f"No rule found for ({left_label}, {right_label}) and no fallback connector"
    )