Skip to content

API Reference

This page is generated directly from the docstrings in the source, so it always matches the installed version. For task-oriented walkthroughs with runnable examples, see the User Guide; this page is the exhaustive signature-level reference.

Scope

Only the public, supported API is documented here. The 3-D visualizer's internal modules (which require the optional vis extras) are intentionally omitted — use the visualize helpers below instead.


Core

Structure

Structure(path: str | None = None)

Holds atom coordinates and atomic numbers for a single molecule.

Load a structure from an XYZ file and centre it at its centre of mass.

If path is None an empty structure is created (useful for testing).

Source code in pyrrhotite/structure.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def __init__(self, path: str | None = None) -> None:
    """Load a structure from an XYZ file and centre it at its centre of mass.

    If path is None an empty structure is created (useful for testing).
    """
    self.num_atoms: int = 0
    self.coordinates: np.ndarray = np.empty((0, 3), dtype=float)
    self.atomic_numbers: np.ndarray = np.empty(0, dtype=int)
    self.description: str = ""
    self.filename: str = ""

    if path is not None:
        self._load_from_file(path)
        self._centre_at_com()

description_filename property

description_filename: str

Return a human-readable label combining description and filename.

find_closest_index

find_closest_index(
    coords: ndarray, atomic_number: int
) -> int

Return the index of the atom of the given element closest to coords.

Only atoms whose atomic number matches are considered, mirroring the original C++ structure.cpp find_closest_index.

This is used during the symmetry search to map a transformed atom position back onto a real atom: after applying a candidate symmetry operation, each atom should land on top of an atom of the same element. If the closest match of the right element is too far away, the operation is rejected.

np.einsum("ij,ij->i", ...) computes the row-wise dot product of a matrix with itself, giving squared distances for all candidate atoms simultaneously without an explicit Python loop.

Source code in pyrrhotite/structure.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def find_closest_index(self, coords: np.ndarray, atomic_number: int) -> int:
    """Return the index of the atom of the given element closest to coords.

    Only atoms whose atomic number matches are considered, mirroring
    the original C++ structure.cpp find_closest_index.

    This is used during the symmetry search to map a transformed atom
    position back onto a real atom: after applying a candidate symmetry
    operation, each atom should land on top of an atom of the same element.
    If the closest match of the right element is too far away, the operation
    is rejected.

    np.einsum("ij,ij->i", ...) computes the row-wise dot product of a
    matrix with itself, giving squared distances for all candidate atoms
    simultaneously without an explicit Python loop.
    """
    mask = self.atomic_numbers == atomic_number
    indices = np.where(mask)[0]
    diffs = self.coordinates[indices] - coords
    sq_dists = np.einsum("ij,ij->i", diffs, diffs)
    return int(indices[np.argmin(sq_dists)])

calculate_bond_pairs

calculate_bond_pairs() -> list[tuple[int, int]]

Return (i, j) index pairs for atoms likely bonded to each other.

Bond criterion: dist² < 20 · rᵢ · rⱼ (covalent radii in Ångströms)

Why this heuristic?

A typical covalent bond length is approximately rᵢ + rⱼ, so the squared bond length is roughly (rᵢ + rⱼ)² ≈ 4 · rᵢ · rⱼ (by the AM-GM inequality when rᵢ ≈ rⱼ). Multiplying by 20 gives a generous cutoff — about 2.2 × the expected bond length — that catches stretched or unusual bonds without false-positives from non-bonded neighbours.

Bond pairs are used by the symmetry search to generate candidate C2 axes (the midpoint bisector of a bond is often a symmetry axis), and to build candidate σ planes.

Source code in pyrrhotite/structure.py
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
157
def calculate_bond_pairs(self) -> list[tuple[int, int]]:
    """Return (i, j) index pairs for atoms likely bonded to each other.

    Bond criterion: dist² < 20 · rᵢ · rⱼ   (covalent radii in Ångströms)

    Why this heuristic?
    -------------------
    A typical covalent bond length is approximately ráµ¢ + râ±¼, so the
    squared bond length is roughly (rᵢ + rⱼ)² ≈ 4 · rᵢ · rⱼ (by the
    AM-GM inequality when rᵢ ≈ rⱼ).  Multiplying by 20 gives a generous
    cutoff — about 2.2 × the expected bond length — that catches stretched
    or unusual bonds without false-positives from non-bonded neighbours.

    Bond pairs are used by the symmetry search to generate candidate C2
    axes (the midpoint bisector of a bond is often a symmetry axis), and
    to build candidate σ planes.
    """
    pairs: list[tuple[int, int]] = []
    for i in range(self.num_atoms - 1):
        ri = get_element(int(self.atomic_numbers[i])).radius
        for j in range(i + 1, self.num_atoms):
            diff = self.coordinates[i] - self.coordinates[j]
            dist2 = float(np.dot(diff, diff))
            rj = get_element(int(self.atomic_numbers[j])).radius
            # Compare squared distance to avoid a square-root per pair.
            if dist2 < 20.0 * ri * rj:
                pairs.append((i, j))
    return pairs

print_atom_list

print_atom_list() -> None

Print a numbered atom index table: index, element symbol, and coordinates.

Use this alongside get_atoms_on_axis() / get_atoms_in_plane() results to identify which atoms correspond to returned indices.

Source code in pyrrhotite/structure.py
166
167
168
169
170
171
172
173
174
175
176
177
def print_atom_list(self) -> None:
    """Print a numbered atom index table: index, element symbol, and coordinates.

    Use this alongside get_atoms_on_axis() / get_atoms_in_plane() results
    to identify which atoms correspond to returned indices.
    """
    print(f"{'#':>4}  {'El':>2}  {'x (Ã…)':>10}  {'y (Ã…)':>10}  {'z (Ã…)':>10}")
    print("─" * 46)
    for i in range(self.num_atoms):
        sym = get_element(int(self.atomic_numbers[i])).symbol
        x, y, z = self.coordinates[i]
        print(f"{i:>4}  {sym:>2}  {x:>10.4f}  {y:>10.4f}  {z:>10.4f}")

Symmetry

Symmetry(structure: Structure)

Runs the full Schoenflies point-group determination pipeline for a Structure.

Store structure, build OperationManager, and run all pipeline steps.

Source code in pyrrhotite/symmetry.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def __init__(self, structure: Structure) -> None:
    """Store structure, build OperationManager, and run all pipeline steps."""
    self._structure: Structure = structure
    self._operation_manager: OperationManager = OperationManager(structure)

    self._principal_moments: np.ndarray = np.zeros(3, dtype=float)
    # columns are eigenvectors (principal axes), matching glm::column(M, i) = M[:, i]
    self._principal_axes: np.ndarray = np.eye(3, dtype=float)

    self._x_axis: np.ndarray = np.full(3, float("nan"))
    self._y_axis: np.ndarray = np.full(3, float("nan"))
    self._z_axis: np.ndarray = np.full(3, float("nan"))

    self._rotor_class: RotorClass = RotorClass.AsymmetricTop
    self._point_group: PointGroup | None = None

    self._determine_principal_axes()
    self._determine_rotor_class()
    self._find_symmetry_operations()
    self._find_point_group()
    self._find_cartesian_axes()
    self._label_symmetry_operations()

    self._operation_manager.generate_point_group_operations(self._point_group)

structure property

structure: Structure

Return the structure used for this symmetry determination.

principal_moments property

principal_moments: ndarray

Return the three principal moments of inertia, sorted ascending.

principal_axes property

principal_axes: ndarray

Return the 3x3 matrix whose columns are the principal axes (eigenvectors).

x_axis property

x_axis: ndarray

Return the Cartesian x axis (set after find_cartesian_axes).

y_axis property

y_axis: ndarray

Return the Cartesian y axis (set after find_cartesian_axes).

z_axis property

z_axis: ndarray

Return the Cartesian z axis (set after find_cartesian_axes).

cartesian_axes property

cartesian_axes: ndarray

Return the 3x3 Cartesian-axis matrix with columns [x, y, z].

rotor_class property

rotor_class: RotorClass

Return the rotor classification of the structure.

point_group property

point_group: PointGroup

Return the determined point group.

operation_manager property

operation_manager: OperationManager

Return the operation manager holding all found symmetry operations.

RotorClass

Bases: Enum

Rigid-rotor type determined from the degeneracy of the principal moments of inertia.

The classification determines which symmetry axes are worth searching for (see Symmetry._axis_inertially_allowed).

Members

AsymmetricTop Ia < Ib < Ic — all three moments are distinct. No rotational symmetry axis is required. Examples: water (C2v), hydrogen peroxide (C2). OblateSymmetricTop Ia ≈ Ib < Ic — the two smaller moments are equal; the unique axis is the short (oblate / "disc-like") axis. Examples: benzene (D6h), ammonia (C3v). ProlateSymmetricTop Ia < Ib ≈ Ic — the two larger moments are equal; the unique axis is the long (prolate / "cigar-like") axis. Examples: chloromethane (C3v), allene (D2d). Linear Ia ≈ 0, Ib ≈ Ic — the molecule lies along a single axis; rotation about that axis produces zero moment. Examples: CO2 (D∞h), HCN (C∞v). SphericalTop Ia ≈ Ib ≈ Ic — all three moments equal; the molecule has no preferred orientation. Examples: methane (Td), sulfur hexafluoride (Oh).


Structure generation

generate_idealized_structure

generate_idealized_structure(
    point_group: str | PointGroupLabel,
    radius: float = 1.0,
    height: float = 0.6,
    element: str = "F",
) -> Structure

Build an idealized Structure with the requested axial point group symmetry.

Parameters:

Name Type Description Default
point_group str | PointGroupLabel

Either a PointGroupLabel or a name string accepted by parse_point_group_name (e.g. "C12v", "D9h", "S8"). Only the seven axial families -- Cn, Cnh, Cnv, Sn, Dn, Dnh, Dnd -- are supported.

required
radius float

Scale factor (default 1.0) applied to the ring radii used for the primary ring(s) of atoms. The default geometry is tuned so that, at radius=1.0, ring atoms bond to exactly their ring neighbours (per Structure.calculate_bond_pairs's dist^2 < 20 * r_i * r_j criterion); scaling radius away from 1.0 may change which atoms end up bonded.

1.0
height float

Scale factor (default 0.6, matching the historical default) applied to the z-offsets used for apex atoms / second rings / hub-to-ring separation, where applicable.

0.6
element str

Placeholder element symbol used for the primary ring(s) of atoms. At the default value ("F"), the primary ring element is instead chosen per family to look like a plausible molecule for that atom's bonding degree (see _ring_element/_element_for_degree); apex, hub, and decoration atoms always use a different element from the primary ring (see _hub_element_for, _decoration_element).

'F'

Returns:

Type Description
Structure

A structure centred at its centre of mass, ready to be passed to Symmetry.

Raises:

Type Description
ValueError

If point_group is not one of the seven supported axial families, or if the order n is out of range for the requested family (n < 3 in general, or n odd / n < 4 for Sn).

Source code in pyrrhotite/structure_generator.py
179
180
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
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
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
325
326
327
328
329
330
331
332
333
334
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
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
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
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def generate_idealized_structure(
    point_group: str | PointGroupLabel,
    radius: float = 1.0,
    height: float = 0.6,
    element: str = "F",
) -> Structure:
    """Build an idealized `Structure` with the requested axial point group symmetry.

    Parameters
    ----------
    point_group:
        Either a `PointGroupLabel` or a name string accepted by
        `parse_point_group_name` (e.g. "C12v", "D9h", "S8"). Only the seven
        axial families -- Cn, Cnh, Cnv, Sn, Dn, Dnh, Dnd -- are supported.
    radius:
        Scale factor (default 1.0) applied to the ring radii used for the
        primary ring(s) of atoms. The default geometry is tuned so that, at
        `radius=1.0`, ring atoms bond to exactly their ring neighbours (per
        `Structure.calculate_bond_pairs`'s `dist^2 < 20 * r_i * r_j`
        criterion); scaling `radius` away from 1.0 may change which atoms end
        up bonded.
    height:
        Scale factor (default 0.6, matching the historical default) applied
        to the z-offsets used for apex atoms / second rings / hub-to-ring
        separation, where applicable.
    element:
        Placeholder element symbol used for the primary ring(s) of atoms. At
        the default value ("F"), the primary ring element is instead chosen
        per family to look like a plausible molecule for that atom's bonding
        degree (see `_ring_element`/`_element_for_degree`); apex, hub, and
        decoration atoms always use a different element from the primary ring
        (see `_hub_element_for`, `_decoration_element`).

    Returns
    -------
    Structure
        A structure centred at its centre of mass, ready to be passed to
        `Symmetry`.

    Raises
    ------
    ValueError
        If `point_group` is not one of the seven supported axial families,
        or if the order `n` is out of range for the requested family
        (n < 3 in general, or n odd / n < 4 for Sn).
    """
    label = _coerce_label(point_group)

    group_class = label.group_class
    n = label.order

    if group_class not in (_Class.C, _Class.Ch, _Class.Cv, _Class.S, _Class.D, _Class.Dh, _Class.Dd):
        raise ValueError(
            f"generate_idealized_structure only supports the axial families "
            f"Cn, Cnh, Cnv, Sn, Dn, Dnh, Dnd; got {label.name!r}"
        )

    if group_class == _Class.S:
        if n < 4 or n % 2 != 0:
            raise ValueError(f"Sn requires an even order n >= 4; got n={n}")
    elif n < 3:
        raise ValueError(f"{label.name!r} requires order n >= 3; got n={n}")

    height_scale = height / 0.6

    if group_class == _Class.Cv:
        # Cnv: ring of terminal ("H"-like) atoms + a single apex atom on the
        # z-axis, mirroring ammonia's N-apex-over-H-ring pattern. The ring
        # radius is sized as a fixed fraction of the apex-ring bonding
        # cutoff -- so the apex always bonds to every ring atom regardless of
        # n -- and the apex sits slightly above the ring plane, which breaks
        # sigma_h and the in-plane C2 axes that would otherwise make this
        # Dnh instead of Cnv. For small/moderate n this also keeps
        # neighbouring ring atoms outside bonding distance of each other
        # (ammonia-like, ring atoms degree 1, apex degree n); for larger n
        # (where ring atoms necessarily crowd closer together as n grows for
        # a fixed radius) neighbouring ring atoms end up bonded too (ring
        # atoms degree 3: 2 ring neighbours + apex). Both patterns are
        # connected and round-trip to Cnv.
        ring_element_name = _ring_element(element, 1)
        ring_z = get_atomic_number(ring_element_name)
        ring_radius_elem = get_element(ring_z).radius

        apex_element_name = _element_for_degree(n)
        apex_z = get_atomic_number(apex_element_name)
        apex_radius_elem = get_element(apex_z).radius

        apex_to_ring_cutoff = np.sqrt(20.0 * apex_radius_elem * ring_radius_elem)

        ring_radius_base = 0.9 * apex_to_ring_cutoff
        ring_radius = ring_radius_base * radius

        # Apex height: place the apex 95% of the way to the apex-ring bonding
        # cutoff (in 3-D distance from each ring atom), so the apex bonds to
        # every ring atom with some margin to spare while staying above the
        # ring plane.
        target_apex_dist = 0.95 * apex_to_ring_cutoff
        apex_height = np.sqrt(target_apex_dist**2 - ring_radius_base**2) * height_scale

        ring_coords = _ring(n, ring_radius, z=0.0)
        coords = np.vstack((np.array([[0.0, 0.0, apex_height]]), ring_coords))
        atomic_numbers = np.concatenate(([apex_z], np.full(n, ring_z, dtype=int)))
        description = f"Idealized {label.name}: {n}-ring + apex"

    elif group_class == _Class.C:
        # Cn: ring1 (main, z=0) + one terminal substituent atom per ring1 atom,
        # placed at a fixed offset (in the local radial/tangential/z frame of
        # each ring1 atom) so the substituent bond length -- and hence its
        # visibility as a separate sphere+cylinder in the viewer -- does not
        # shrink as the ring grows with n. The tangential and z components of
        # the offset break sigma_h, sigma_v, and S2n while preserving Cn. Each
        # ring1 atom bonds to its 2 ring1 neighbours + its substituent (degree
        # 3); substituent atoms are terminal (degree 1).
        ring1_element_name = _ring_element(element, 3)
        ring1_z = get_atomic_number(ring1_element_name)
        ring1_radius = _radius_for(n) * radius
        ring1 = _ring(n, ring1_radius, z=0.0)

        # Local frame at each ring1 atom: radial (outward), tangential (along
        # the ring), and z. The offset magnitude (~0.9 A) is chosen so the
        # substituent bonds to its own ring1 atom (within the substituent-ring1
        # bonding cutoff) but stays clear of both ring1's neighbouring atoms
        # and other substituents -- including at large n, where ring1's
        # nearest-neighbour spacing shrinks towards ~1.35 A (the tangential
        # component must stay well below that).
        thetas = 2.0 * np.pi * np.arange(n) / n
        radial = np.column_stack((np.cos(thetas), np.sin(thetas), np.zeros(n)))
        tangential = np.column_stack((-np.sin(thetas), np.cos(thetas), np.zeros(n)))
        offset = 0.3 * radial + 0.2 * tangential + np.array([0.0, 0.0, 0.85 * height_scale])
        ring2 = ring1 + offset

        decoration_z = get_atomic_number(_decoration_element(element))
        coords = np.vstack((ring1, ring2))
        atomic_numbers = np.concatenate((np.full(n, ring1_z, dtype=int), np.full(n, decoration_z, dtype=int)))
        description = f"Idealized {label.name}: {n}-ring + terminal substituents"

    elif group_class == _Class.Ch:
        # Cnh: ring1 (main) + ring2 (terminal substituent ring), both planar at
        # z=0 with a small angular offset. A planar arrangement is automatically
        # invariant under sigma_h (the molecular plane itself); the generic
        # offset avoids accidentally introducing sigma_v / extra C2 axes, which
        # would promote this to Dnh/Cnv. Each ring1 atom bonds to its 2 ring1
        # neighbours + 1 ring2 (terminal) atom (degree 3); ring2 atoms are
        # terminal (degree 1).
        ring1_z = get_atomic_number(_ring_element(element, 3))
        upper = 1.75 / (2.0 * np.sin(np.pi / n))
        r1 = min(max(1.1, 0.5 / np.sin(np.pi / n)), upper) * radius
        eps = (2.0 * np.pi / n) * 0.05
        r2_factor = 1.8 if n <= 8 else 1.8 - 0.05 * (n - 8)
        ring1 = _ring(n, r1, z=0.0)
        ring2 = _ring(n, r1 * r2_factor, z=0.0, phase=eps)
        decoration_z = get_atomic_number(_decoration_element(element))
        coords = np.vstack((ring1, ring2))
        atomic_numbers = np.concatenate((np.full(n, ring1_z, dtype=int), np.full(n, decoration_z, dtype=int)))
        description = f"Idealized {label.name}: planar {n}-ring + terminal {n}-ring"

    elif group_class in (_Class.D, _Class.Dh, _Class.Dd):
        # Dn / Dnh / Dnd: a central hub atom (different, larger-radius element,
        # mirroring ferrocene's Fe) plus two parallel n-gon rings, related by a
        # twist angle theta: theta=0 (eclipsed prism, Dnh, like ferrocene-eclipsed),
        # theta=pi/(2n) (generic twist, Dn, chiral), or theta=pi/n (staggered
        # antiprism, Dnd, like ferrocene-staggered). The ring separation is
        # large enough that the two rings are not directly bonded to each
        # other -- they are connected only through the hub. Each ring atom
        # bonds to its 2 ring neighbours + the hub (degree 3); the hub bonds to
        # all 2n ring atoms.
        if group_class == _Class.Dh:
            theta = 0.0
        elif group_class == _Class.Dd:
            theta = np.pi / n
        else:
            theta = np.pi / (2 * n)

        ring_z = get_atomic_number(_ring_element(element, 3))
        ring_radius_elem = get_element(ring_z).radius
        base_height = 0.8 if (group_class == _Class.Dd and n in (3, 4)) else 1.0
        ring_height = base_height * height_scale

        # Hub element is sized to the ring: the smallest hub that still bonds to
        # the ring at its natural radius (so small n gets a small hub instead of
        # a giant caesium sphere swallowing the rings), escalating to caesium
        # only for large n where the rings sit far out -- see `_hub_element_for`.
        natural_radius = _radius_for(n) * radius
        hub_z = get_atomic_number(
            _hub_element_for(natural_radius, ring_height, ring_radius_elem, element)
        )

        # Ring radius: large enough that adjacent ring atoms bond to each other,
        # but capped so the central hub stays within bonding distance of every
        # ring atom even for large n. `_radius_for(n)` grows ~linearly with n, so
        # without the cap the rings would drift outside the hub-ring bonding
        # cutoff (e.g. for D20h the hub-ring distance reaches ~4.4 A versus a
        # ~3.1 A cutoff), leaving the hub bonded to nothing -- a central atom
        # floating unconnected between the two rings in the 3-D viewer. Capping
        # the radius instead lets neighbouring ring atoms crowd closer (so they
        # also bond, raising the ring-atom degree) while keeping the hub bonded;
        # both patterns stay connected and round-trip correctly through Symmetry.
        # (For the hubs `_hub_element_for` picked for their natural radius, the
        # cap is a no-op; it only bites in the large-n caesium fallback.)
        hub_ring_cutoff = np.sqrt(20.0 * get_element(hub_z).radius * ring_radius_elem)
        max_ring_radius = _hub_bonded_ring_radius(hub_ring_cutoff, ring_height)
        ring_radius = min(natural_radius, max_ring_radius)

        ring_top = _ring(n, ring_radius, z=ring_height)
        ring_bottom = _ring(n, ring_radius, z=-ring_height, phase=theta)
        coords = np.vstack((np.array([[0.0, 0.0, 0.0]]), ring_top, ring_bottom))
        atomic_numbers = np.concatenate(([hub_z], np.full(2 * n, ring_z, dtype=int)))
        description = f"Idealized {label.name}: hub + twisted double {n}-ring (theta={theta:.4f} rad)"

    elif group_class == _Class.S:
        # Sn (n even): a central hub atom + an (n/2)-gon antiprism (two rings
        # of m=n/2 atoms, staggered by the Sn rotation angle 2*pi/n, separated
        # widely enough to avoid direct cross-ring bonds) + small "marker"
        # atoms on each ring atom, angularly offset by a generic delta
        # consistent with the Sn operation (top markers offset by delta,
        # bottom markers offset by theta+delta, matching how Sn maps top atoms
        # to bottom atoms). The antiprism + hub alone has D_{(n/2)d} symmetry
        # (a strict superset of Sn); the markers break its extra C2 axes /
        # sigma_d planes while preserving Sn. Each main ring atom bonds to its
        # 2 ring neighbours + the hub + its own marker (degree 4, like a
        # substituted ferrocene ring carbon); markers are terminal (or, for
        # n=4, also reach the hub).
        m = n // 2
        theta = np.pi / m
        delta = (2.0 * np.pi / n) * 0.25
        mr_factor = 1.05

        ring_z = get_atomic_number(_ring_element(element, 4))
        ring_radius_elem = get_element(ring_z).radius
        ring_height = 1.0 * height_scale

        # Natural antiprism radius (before the hub-bonding cap below). n=4 uses a
        # fixed small radius; otherwise the m=n/2-gon's neighbour-bonding radius.
        if n == 4:
            natural_radius = 0.7 * radius
            mz_off = 1.2 * height_scale
        else:
            natural_radius = _radius_for(m) * radius
            mz_off = {6: 1.25, 8: 1.2, 10: 1.1}.get(n, 1.2) * height_scale

        # Hub element sized to the ring (smallest hub that still bonds at the
        # natural radius; caesium only as a large-n fallback) -- same dynamic
        # sizing as the Dn/Dnh/Dnd families, see `_hub_element_for`.
        hub_z = get_atomic_number(
            _hub_element_for(natural_radius, ring_height, ring_radius_elem, element)
        )

        # Cap the antiprism radius so the central hub stays bonded to the ring
        # atoms even for large n (same reasoning as the Dn/Dnh/Dnd families: the
        # m=n/2-gon radius from `_radius_for` grows with n and would otherwise
        # carry the rings beyond the hub-ring bonding cutoff).
        hub_ring_cutoff = np.sqrt(20.0 * get_element(hub_z).radius * ring_radius_elem)
        max_ring_radius = _hub_bonded_ring_radius(hub_ring_cutoff, ring_height)
        ring_radius = min(natural_radius, max_ring_radius)

        ring_top = _ring(m, ring_radius, z=ring_height)
        ring_bottom = _ring(m, ring_radius, z=-ring_height, phase=theta)
        marker_top = _ring(m, ring_radius * mr_factor, z=ring_height + mz_off, phase=delta)
        marker_bottom = _ring(m, ring_radius * mr_factor, z=-(ring_height + mz_off), phase=theta + delta)

        marker_z = get_atomic_number(_decoration_element(element))
        coords = np.vstack((np.array([[0.0, 0.0, 0.0]]), ring_top, ring_bottom, marker_top, marker_bottom))
        atomic_numbers = np.concatenate(
            ([hub_z], np.full(2 * m, ring_z, dtype=int), np.full(2 * m, marker_z, dtype=int))
        )
        description = f"Idealized {label.name}: hub + {m}-gon antiprism + Sn-consistent markers"

    structure = Structure(None)
    structure.num_atoms = coords.shape[0]
    structure.coordinates = coords
    structure.atomic_numbers = atomic_numbers
    structure.description = description
    structure.filename = ""
    structure._centre_at_com()
    return structure

write_xyz

write_xyz(structure: Structure, path: str | Path) -> None

Write structure to path in standard XYZ format (see format_xyz).

Source code in pyrrhotite/structure_generator.py
471
472
473
474
def write_xyz(structure: Structure, path: str | Path) -> None:
    """Write `structure` to `path` in standard XYZ format (see `format_xyz`)."""
    with open(path, "w") as fh:
        fh.write(format_xyz(structure))

format_xyz

format_xyz(structure: Structure) -> str

Return structure formatted as standard XYZ text.

The output mirrors the format read by Structure._load_from_xyz: an atom-count line, a comment line (structure.description), then one <symbol> x y z line per atom.

Source code in pyrrhotite/structure_generator.py
456
457
458
459
460
461
462
463
464
465
466
467
468
def format_xyz(structure: Structure) -> str:
    """Return `structure` formatted as standard XYZ text.

    The output mirrors the format read by `Structure._load_from_xyz`: an
    atom-count line, a comment line (`structure.description`), then one
    `<symbol> x y z` line per atom.
    """
    lines = [str(structure.num_atoms), structure.description]
    for i in range(structure.num_atoms):
        symbol = get_element(int(structure.atomic_numbers[i])).symbol
        x, y, z = structure.coordinates[i]
        lines.append(f"{symbol}  {x:.7f}  {y:.7f}  {z:.7f}")
    return "\n".join(lines) + "\n"

Character tables

get_or_generate_point_group module-attribute

get_or_generate_point_group = find_point_group

generate_point_group

generate_point_group(
    label: PointGroupLabel | str,
) -> PointGroup

Generate a PointGroup with full character table for any axial point group.

This is the public entry point called by symmetry.py when no hardcoded character table matches the detected operations (e.g. a molecule with a C15 axis). It dispatches to the appropriate build* function based on the group class (Cn, Cnh, Cnv, Sn, Dn, Dnh, Dnd).

label may be a PointGroupLabel or a Schoenflies symbol string (e.g. "C12v"), in which case it is parsed via parse_point_group_name.

Polyhedral groups (T, O, I) and linear groups (C∞v, D∞h) are NOT supported here — they use hardcoded tables in point_groups.py because their structure does not fit the uniform axial formulas.

Supported families: Cn, Cnh, Cnv, Sn (n even ≥ 4), Dn, Dnh, Dnd. Raises ValueError for unsupported families (polyhedral, linear, n < 2).

Source code in pyrrhotite/character_tables/generator.py
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
def generate_point_group(label: PointGroupLabel | str) -> PointGroup:
    """Generate a PointGroup with full character table for any axial point group.

    This is the public entry point called by symmetry.py when no hardcoded
    character table matches the detected operations (e.g. a molecule with a
    C15 axis).  It dispatches to the appropriate _build_* function based on
    the group class (Cn, Cnh, Cnv, Sn, Dn, Dnh, Dnd).

    *label* may be a PointGroupLabel or a Schoenflies symbol string (e.g.
    "C12v"), in which case it is parsed via parse_point_group_name.

    Polyhedral groups (T, O, I) and linear groups (C∞v, D∞h) are NOT supported
    here — they use hardcoded tables in point_groups.py because their structure
    does not fit the uniform axial formulas.

    Supported families: Cn, Cnh, Cnv, Sn (n even ≥ 4), Dn, Dnh, Dnd.
    Raises ValueError for unsupported families (polyhedral, linear, n < 2).
    """
    label = _coerce_label(label)
    cls = label.group_class
    n = label.order

    if cls not in _SUPPORTED_CLASSES:
        raise ValueError(
            f"generate_point_group does not support class {cls.name}; "
            "polyhedral and linear groups use hardcoded tables."
        )
    if n < 2:
        raise ValueError(f"Order must be ≥ 2 for axial groups, got n={n}")
    if cls == _C.S and n % 2 != 0:
        raise ValueError(f"Sn requires even n, got n={n}")
    if cls == _C.S and n < 4:
        raise ValueError(f"Sn requires n ≥ 4, got n={n}")

    return _BUILDERS[cls](n)

find_point_group

find_point_group(
    label: PointGroupLabel | str,
) -> PointGroup | None

Return a PointGroup by label: hardcoded table first, generator fallback.

label may be a PointGroupLabel or a Schoenflies symbol string (e.g. "D6h"), in which case it is parsed via parse_point_group_name.

Returns None if the label is not in POINT_GROUPS and cannot be generated (e.g. polyhedral or linear groups not in the hardcoded list).

Source code in pyrrhotite/character_tables/generator.py
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
def find_point_group(label: PointGroupLabel | str) -> PointGroup | None:
    """Return a PointGroup by label: hardcoded table first, generator fallback.

    *label* may be a PointGroupLabel or a Schoenflies symbol string (e.g.
    "D6h"), in which case it is parsed via parse_point_group_name.

    Returns None if the label is not in POINT_GROUPS and cannot be generated
    (e.g. polyhedral or linear groups not in the hardcoded list).
    """
    label = _coerce_label(label)
    # Search hardcoded list first
    for pg in POINT_GROUPS:
        pg_lbl = pg.label
        if (pg_lbl.group_class == label.group_class
                and pg_lbl.order == label.order):
            return pg

    # Try generator
    try:
        return generate_point_group(label)
    except ValueError:
        return None

parse_point_group_name

parse_point_group_name(name: str) -> PointGroupLabel

Parse a Schoenflies point group name string into a PointGroupLabel.

Accepted formats

Fixed-order groups (no integer in the name): "C1", "Ci", "Cs" "T", "Td", "Th", "O", "Oh", "I", "Ih" "C∞v" or "Cinfv" (linear, no inversion) "D∞h" or "Dinfh" (linear, with inversion)

Axial groups with integer order n: Cyclic: "Cn" e.g. "C3", "C11" Horizontal: "Cnh" e.g. "C3h", "C10h" Pyramidal: "Cnv" e.g. "C3v", "C6v" Improper: "Sn" e.g. "S4", "S12" (n must be even and ≥ 4) Dihedral: "Dn" e.g. "D3", "D6" Prismatic: "Dnh" e.g. "D3h", "D6h" Antiprismatic:"Dnd" e.g. "D3d", "D4d"

The name is case-sensitive for the leading letter (C, D, S, T, O, I) and case-insensitive for the suffix (h, v, d).

Examples:

>>> parse_point_group_name("C3v")
PointGroupLabel(Cv, 3)
>>> parse_point_group_name("D6h")
PointGroupLabel(Dh, 6)
>>> parse_point_group_name("Oh")
PointGroupLabel(Oh)

Raises:

Type Description
ValueError

If the string cannot be parsed or the combination is invalid.

Source code in pyrrhotite/character_tables/generator.py
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
def parse_point_group_name(name: str) -> PointGroupLabel:
    """Parse a Schoenflies point group name string into a PointGroupLabel.

    Accepted formats
    ----------------
    Fixed-order groups (no integer in the name):
      "C1", "Ci", "Cs"
      "T", "Td", "Th", "O", "Oh", "I", "Ih"
      "C∞v" or "Cinfv"  (linear, no inversion)
      "D∞h" or "Dinfh"  (linear, with inversion)

    Axial groups with integer order *n*:
      Cyclic:       "Cn"          e.g. "C3", "C11"
      Horizontal:   "Cnh"         e.g. "C3h", "C10h"
      Pyramidal:    "Cnv"         e.g. "C3v", "C6v"
      Improper:     "Sn"          e.g. "S4", "S12"  (n must be even and ≥ 4)
      Dihedral:     "Dn"          e.g. "D3", "D6"
      Prismatic:    "Dnh"         e.g. "D3h", "D6h"
      Antiprismatic:"Dnd"         e.g. "D3d", "D4d"

    The name is case-sensitive for the leading letter (C, D, S, T, O, I)
    and case-insensitive for the suffix (h, v, d).

    Examples
    --------
    >>> parse_point_group_name("C3v")
    PointGroupLabel(Cv, 3)
    >>> parse_point_group_name("D6h")
    PointGroupLabel(Dh, 6)
    >>> parse_point_group_name("Oh")
    PointGroupLabel(Oh)

    Raises
    ------
    ValueError
        If the string cannot be parsed or the combination is invalid.
    """
    stripped = name.strip()

    # 1. Fixed-name lookup (case-sensitive as written by get_name())
    if stripped in _FIXED_NAMES:
        return _FIXED_NAMES[stripped]

    # 2. Case-insensitive fallback for the fixed names
    lower = stripped.lower()
    for key, lbl in _FIXED_NAMES.items():
        if key.lower() == lower:
            return lbl

    # 3. Axial groups: split into (leading letter(s)) + (digits) + (trailing suffix)
    import re
    m = re.fullmatch(r'([CDScdsi])(\d+)([hvdHVD]?)', stripped)
    if m is None:
        raise ValueError(
            f"Cannot parse point group name '{name}'. "
            "Expected a Schoenflies symbol such as 'C3v', 'D5h', 'S8', 'D3d'. "
            "See parse_point_group_name docstring for the full list of accepted formats."
        )

    letter  = m.group(1).upper()   # C, D, or S
    n       = int(m.group(2))
    suffix  = m.group(3).lower()   # h, v, d, or empty

    if letter == "S":
        if suffix:
            raise ValueError(f"Sn groups have no suffix; got '{name}'.")
        pg_class = _C.S
    elif letter == "C":
        pg_class = {"h": _C.Ch, "v": _C.Cv, "": _C.C}.get(suffix)
        if pg_class is None:
            raise ValueError(f"Unknown suffix '{suffix}' for C-type group in '{name}'.")
    elif letter == "D":
        pg_class = {"h": _C.Dh, "d": _C.Dd, "": _C.D}.get(suffix)
        if pg_class is None:
            raise ValueError(f"Unknown suffix '{suffix}' for D-type group in '{name}'.")
    else:
        raise ValueError(f"Unexpected leading letter '{letter}' in '{name}'.")

    return PointGroupLabel(pg_class, n)

print_character_table_for

print_character_table_for(name: str) -> None

Look up or generate the character table for a point group and print it.

The name must be a Schoenflies symbol in the format described by :func:parse_point_group_name. Short summary of accepted formats:

  • Fixed groups — "C1", "Ci", "Cs", "T", "Td", "Th", "O", "Oh", "I", "Ih", "C∞v" / "Cinfv", "D∞h" / "Dinfh"
  • Axial groups — "Cn", "Cnh", "Cnv", "Sn", "Dn", "Dnh", "Dnd" where n is any valid integer (e.g. "C3v", "D6h", "S12", "D11", "C20v")

Groups already in the hardcoded POINT_GROUPS list are used directly; all others are generated on-the-fly via the analytical formulas.

Parameters:

Name Type Description Default
name str

Schoenflies symbol (case-sensitive leading letter, case-insensitive suffix). Whitespace is stripped automatically.

required

Raises:

Type Description
ValueError

If name cannot be parsed or the requested group is unavailable (e.g. polyhedral groups with n > hardcoded limit have no generator).

Examples:

>>> print_character_table_for("C3v")
C3v  |     E |   2C3 |   3σv
----------------------------
A1   |     1 |     1 |     1
A2   |     1 |     1 |    -1
E    |     2 |    -1 |     0
>>> print_character_table_for("D6h")
...full 16-row D6h table...
>>> print_character_table_for("C12v")
...generated C12v table for n=12...
Source code in pyrrhotite/character_tables/generator.py
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
def print_character_table_for(name: str) -> None:
    """Look up or generate the character table for a point group and print it.

    The *name* must be a Schoenflies symbol in the format described by
    :func:`parse_point_group_name`.  Short summary of accepted formats:

    * Fixed groups — ``"C1"``, ``"Ci"``, ``"Cs"``, ``"T"``, ``"Td"``,
      ``"Th"``, ``"O"``, ``"Oh"``, ``"I"``, ``"Ih"``,
      ``"C∞v"`` / ``"Cinfv"``, ``"D∞h"`` / ``"Dinfh"``
    * Axial groups — ``"Cn"``, ``"Cnh"``, ``"Cnv"``, ``"Sn"``,
      ``"Dn"``, ``"Dnh"``, ``"Dnd"`` where *n* is any valid integer
      (e.g. ``"C3v"``, ``"D6h"``, ``"S12"``, ``"D11"``, ``"C20v"``)

    Groups already in the hardcoded POINT_GROUPS list are used directly;
    all others are generated on-the-fly via the analytical formulas.

    Parameters
    ----------
    name:
        Schoenflies symbol (case-sensitive leading letter, case-insensitive
        suffix).  Whitespace is stripped automatically.

    Raises
    ------
    ValueError
        If *name* cannot be parsed or the requested group is unavailable
        (e.g. polyhedral groups with n > hardcoded limit have no generator).

    Examples
    --------
    >>> print_character_table_for("C3v")
    C3v  |     E |   2C3 |   3σv
    ----------------------------
    A1   |     1 |     1 |     1
    A2   |     1 |     1 |    -1
    E    |     2 |    -1 |     0

    >>> print_character_table_for("D6h")
    ...full 16-row D6h table...

    >>> print_character_table_for("C12v")
    ...generated C12v table for n=12...
    """
    label = parse_point_group_name(name)
    pg = find_point_group(label)
    if pg is None:
        raise ValueError(
            f"No character table available for '{name}'. "
            "Polyhedral and linear groups must be in the hardcoded list."
        )
    pg.print_character_table()

format_html

format_html(names: list[str]) -> str

Return HTML code for one or more named point group character tables.

The returned string is a <style> block followed by one <table> per group — suitable for embedding in an existing HTML page. For a complete standalone document use :func:save_html.

Source code in pyrrhotite/character_tables/html_formatter.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def format_html(names: list[str]) -> str:
    """Return HTML code for one or more named point group character tables.

    The returned string is a ``<style>`` block followed by one ``<table>``
    per group — suitable for embedding in an existing HTML page.  For a
    complete standalone document use :func:`save_html`.
    """
    tables: list[str] = []
    for name in names:
        pg = get_or_generate_point_group(name)
        if pg is None:
            raise ValueError(f"Unknown or unsupported point group: '{name}'")
        tables.append(_table_html(pg))

    return _CSS + "\n\n" + "\n\n".join(tables)

save_html

save_html(
    names: list[str], path: str | None = None
) -> Path

Save a standalone HTML document with the requested character tables.

Parameters:

Name Type Description Default
names list[str]

One or more Schoenflies group names, e.g. ["C3v", "D6h"].

required
path str | None

Destination file path. If None, an automatic name is generated from the group names, e.g. C3v_D6h_html.html.

None

Returns:

Type Description
Path

The path of the written file.

Source code in pyrrhotite/character_tables/html_formatter.py
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def save_html(names: list[str], path: str | None = None) -> Path:
    """Save a standalone HTML document with the requested character tables.

    Parameters
    ----------
    names:
        One or more Schoenflies group names, e.g. ``["C3v", "D6h"]``.
    path:
        Destination file path.  If *None*, an automatic name is generated
        from the group names, e.g. ``C3v_D6h_html.html``.

    Returns
    -------
    Path
        The path of the written file.
    """
    if path is None:
        stem = "_".join(n.replace("/", "-") for n in names)
        out_path = Path(stem + "_html.html")
    else:
        out_path = Path(path)

    tables: list[str] = []
    titles: list[str] = []
    for name in names:
        pg = get_or_generate_point_group(name)
        if pg is None:
            raise ValueError(f"Unknown or unsupported point group: '{name}'")
        tables.append(_table_html(pg))
        titles.append(pg.label.name)

    page_title = "Character Tables — " + ", ".join(titles)
    tables_html = "\n\n".join(tables)

    doc = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{page_title}</title>
{_CSS}
</head>
<body>
  <h1>{page_title}</h1>

{tables_html}

</body>
</html>
"""

    out_path.write_text(doc, encoding="utf-8")
    return out_path

format_latex

format_latex(names: list[str]) -> str

Return LaTeX code for one or more named point group character tables.

The returned string contains bare table environments suitable for pasting into a LaTeX document that loads the booktabs and amsmath packages. For a standalone compilable document use :func:save_latex.

Source code in pyrrhotite/character_tables/latex_formatter.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
def format_latex(names: list[str]) -> str:
    """Return LaTeX code for one or more named point group character tables.

    The returned string contains bare table environments suitable for pasting
    into a LaTeX document that loads the ``booktabs`` and ``amsmath`` packages.
    For a standalone compilable document use :func:`save_latex`.
    """
    header = (
        "% Required packages: \\usepackage{booktabs} \\usepackage{amsmath}\n"
    )
    tables: list[str] = []
    for name in names:
        pg = get_or_generate_point_group(name)
        if pg is None:
            raise ValueError(f"Unknown or unsupported point group: '{name}'")
        tables.append(_table_latex(pg))

    return header + "\n\n".join(tables)

save_latex

save_latex(
    names: list[str], path: str | None = None
) -> Path

Save a standalone LaTeX document with the requested character tables.

Parameters:

Name Type Description Default
names list[str]

One or more Schoenflies group names, e.g. ["C3v", "D6h"].

required
path str | None

Destination file path. If None, an automatic name is generated from the group names, e.g. C3v_D6h_latex.tex.

None

Returns:

Type Description
Path

The path of the written file.

Source code in pyrrhotite/character_tables/latex_formatter.py
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
def save_latex(names: list[str], path: str | None = None) -> Path:
    """Save a standalone LaTeX document with the requested character tables.

    Parameters
    ----------
    names:
        One or more Schoenflies group names, e.g. ``["C3v", "D6h"]``.
    path:
        Destination file path.  If *None*, an automatic name is generated
        from the group names, e.g. ``C3v_D6h_latex.tex``.

    Returns
    -------
    Path
        The path of the written file.
    """
    if path is None:
        stem = "_".join(n.replace("/", "-") for n in names)
        out_path = Path(stem + "_latex.tex")
    else:
        out_path = Path(path)

    tables: list[str] = []
    for name in names:
        pg = get_or_generate_point_group(name)
        if pg is None:
            raise ValueError(f"Unknown or unsupported point group: '{name}'")
        tables.append(_table_latex(pg))

    body = "\n\n".join(tables)

    doc = "\n".join([
        r"\documentclass{article}",
        r"\usepackage{booktabs}",
        r"\usepackage{amsmath}",
        r"\begin{document}",
        "",
        body,
        "",
        r"\end{document}",
    ])

    out_path.write_text(doc, encoding="utf-8")
    return out_path

Point groups & basis functions

PointGroup

PointGroup(
    label: PointGroupLabel,
    order: int,
    num_inversions: int,
    num_proper_rotations: dict[int, int],
    num_improper_rotations: dict[int, int],
    num_reflections: int,
    unique_operations: list[OperationLabelCount],
    irreps: list[IrrepLabel],
    characters: list[list[float]],
)

A crystallographic point group with its symmetry operations, irreps, and character table.

Construct a PointGroup with full symmetry data.

For rotation counts, degenerate rotations around the same axis (e.g. C3 and C3^2) are counted once — degree is the key in num_proper/improper_rotations.

Source code in pyrrhotite/point_groups/point_group.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def __init__(
    self,
    label: PointGroupLabel,
    order: int,
    num_inversions: int,
    num_proper_rotations: dict[int, int],
    num_improper_rotations: dict[int, int],
    num_reflections: int,
    unique_operations: list[OperationLabelCount],
    irreps: list[IrrepLabel],
    characters: list[list[float]],
) -> None:
    """Construct a PointGroup with full symmetry data.

    For rotation counts, degenerate rotations around the same axis (e.g. C3 and
    C3^2) are counted once — degree is the key in num_proper/improper_rotations.
    """
    self._label = label
    self._order = order
    self._num_inversions = num_inversions
    self._num_proper_rotations = num_proper_rotations
    self._num_improper_rotations = num_improper_rotations
    self._num_reflections = num_reflections
    self._unique_operations = unique_operations
    self._irreps = irreps
    self._characters = characters

label property

label: PointGroupLabel

Return the point-group label.

order property

order: int

Return the total number of unique symmetry operations.

num_proper_rotations property

num_proper_rotations: dict[int, int]

Return the per-degree count of proper-rotation axes this group requires.

The key is the rotation degree (e.g. 3 for a C3 axis); degree 0 denotes the infinite-order axis C∞ of the linear groups. Used by the matcher to check whether a candidate group can account for the highest-order axis actually detected on a molecule.

num_improper_rotations property

num_improper_rotations: dict[int, int]

Return the per-degree count of improper-rotation (Sâ‚™) axes this group requires.

The key is the improper-rotation degree (e.g. 16 for an S16 axis); degree 0 denotes the infinite-order S∞ of the linear groups. Used by the matcher to check whether a candidate group can host the highest-order improper axis actually detected on a molecule.

unique_operations property

unique_operations: list[OperationLabelCount]

Return the list of unique operation labels with counts.

irreps property

irreps: list[IrrepLabel]

Return the irreducible representations of this point group.

characters property

characters: list[list[float]]

Return the character table indexed as [irrep][operation class].

compare_to_symmetry_operations

compare_to_symmetry_operations(
    operations: list[Operation],
) -> int

Compare this point group against a list of found symmetry operations.

Returns -1 if any required operation type is absent, or a non-negative integer counting how many found operations are not required by this group (the surplus). The caller selects the group with the smallest surplus.

Source code in pyrrhotite/point_groups/point_group.py
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def compare_to_symmetry_operations(self, operations: list[Operation]) -> int:
    """Compare this point group against a list of found symmetry operations.

    Returns -1 if any required operation type is absent, or a non-negative
    integer counting how many found operations are not required by this group
    (the surplus).  The caller selects the group with the smallest surplus.
    """
    # Algorithm: start with the total count of found operations, then
    # subtract one for each operation that the group *requires*.
    # If any required type is missing entirely, return -1 immediately.
    # Whatever remains after all subtractions is the "surplus" — extra
    # operations we found that this group does not need.  A surplus of 0
    # is a perfect match; a small positive surplus is a near-match.
    # The caller picks the group with the smallest non-negative surplus.
    num_remaining = len(operations)

    # count inversions and reflections in the found set
    num_inversions = 0
    num_reflections = 0
    for op in operations:
        element = op.label.element
        if element == OperationLabel.Element.Inversion:
            num_inversions += 1
        if element == OperationLabel.Element.Reflection:
            num_reflections += 1

    if num_inversions < self._num_inversions:
        return -1
    num_remaining -= self._num_inversions

    if num_reflections < self._num_reflections:
        return -1
    num_remaining -= self._num_reflections

    # check proper rotations per degree
    for degree, num_required in self._num_proper_rotations.items():
        num_found = sum(
            1 for op in operations
            if op.label.element == OperationLabel.Element.ProperRotation
            and op.degree == degree
        )
        if num_found < num_required:
            return -1
        num_remaining -= num_required

    # check improper rotations per degree
    for degree, num_required in self._num_improper_rotations.items():
        num_found = sum(
            1 for op in operations
            if op.label.element == OperationLabel.Element.ImproperRotation
            and op.degree == degree
        )
        if num_found < num_required:
            return -1
        num_remaining -= num_required

    return num_remaining

print_character_table

print_character_table(
    *, complex: bool = False, plain: bool = False
) -> None

Print the character table to stdout.

Parameters:

Name Type Description Default
complex bool

When True, split each real 2D E-type irrep into two complex 1D rows showing ε^(jk) and ε^*(jk) characters (only for pure cyclic / Sn groups where this is meaningful; other groups fall back to real rows).

False
plain bool

When True, use the plain-text formatter regardless of whether rich is installed. When False (default), use the rich table renderer for a cleaner, terminal-width-aware layout; falls back to plain text if rich is not available.

False
Source code in pyrrhotite/point_groups/point_group.py
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
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
325
326
327
328
329
330
331
332
333
334
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
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
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
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
473
474
475
def print_character_table(self, *, complex: bool = False, plain: bool = False) -> None:
    """Print the character table to stdout.

    Parameters
    ----------
    complex:
        When True, split each real 2D E-type irrep into two complex 1D rows
        showing ε^(jk) and ε^*(jk) characters (only for pure cyclic / Sn groups
        where this is meaningful; other groups fall back to real rows).
    plain:
        When True, use the plain-text formatter regardless of whether `rich`
        is installed.  When False (default), use the `rich` table renderer for
        a cleaner, terminal-width-aware layout; falls back to plain text if
        `rich` is not available.
    """
    import sys
    import math as _math

    # ------------------------------------------------------------------
    # Shared helpers
    # ------------------------------------------------------------------

    def _safe(text: str) -> str:
        """Return `text`, or an ASCII-substituted version if stdout cannot encode it."""
        # Some terminals (Windows cmd, certain SSH sessions) cannot encode
        # the Unicode characters used in Schoenflies/Mulliken notation.
        # We try to encode the string first; if that raises an exception
        # we fall back to ASCII-safe substitutions so output is never garbled.
        # Substitutions:  σ→s, ∞→inf, ′→', ″→'', −→-, ε→e, ²→^2, ¹→(nothing)
        try:
            text.encode(sys.stdout.encoding or "utf-8")
            return text
        except (UnicodeEncodeError, LookupError):
            return (text
                    .replace("σ", "s").replace("∞", "inf")
                    .replace("′", "'").replace("″", "''")
                    .replace("−", "-").replace("ε", "e")
                    .replace("²", "^2").replace("¹", ""))

    def fmt(v: float) -> str:
        """Format one character value: symbolic constant, integer, or trimmed decimal."""
        # Format a single character-table value for display.
        # Three rules, tried in order:
        #   1. If the value matches a known irrational constant (e.g. √2, φ),
        #      return the symbolic name — avoids ugly floats like "1.4142".
        #   2. If the value is an exact integer (e.g. 1.0, -1.0, 2.0),
        #      return the integer string — "1" not "1.0000".
        #   3. Otherwise, show 4 decimal places and strip trailing zeros.
        sym = _float_to_symbol(v)
        if sym is not None:
            return sym
        return str(int(v)) if v == int(v) else f"{v:.4f}".rstrip("0")

    # ------------------------------------------------------------------
    # Complex-mode helpers
    # ------------------------------------------------------------------

    def _eps_symbol(exp: int, n: int) -> str:
        """Return symbolic ε^exp for the group of order n (ε = e^(2πi/n))."""
        e = exp % n
        if e == 0:
            return "1"
        if e == n // 2 and n % 2 == 0:
            return "-1"
        return "ε" if e == 1 else f"ε^{e}"

    def _is_pure_cyclic() -> bool:
        """True if this group has no reflections and no inversion (Cn or Sn)."""
        return self._num_reflections == 0 and self._num_inversions == 0

    def _group_order_n() -> int | None:
        """Infer n from the principal-axis column (first unique op), or None."""
        if not self._unique_operations:
            return None
        lbl = self._unique_operations[0].label
        from ..operations.operation_label import OperationLabel as _OL
        if lbl.element in (_OL.Element.ProperRotation, _OL.Element.ImproperRotation):
            return lbl.degree
        return None

    def _build_rows() -> list[tuple[str, list[str]]]:
        """Build the list of (row_label, [cell_strings]) for the table body.

        Each irrep normally produces one row.  In *complex mode* (enabled
        when `complex=True` and the group is a pure cyclic/Sn group), each
        doubly-degenerate E_j irrep is instead split into two complex
        conjugate rows:
            top row  — characters ε^(jk), where ε = e^(2πi/n)
            bottom row — characters ε^(-jk) (= complex conjugate)
        This is the "complex character table" form used in some textbooks
        to make the E-type characters look like 1D representations.
        The label of the top row gets a "{" suffix to visually pair the rows.

        For non-E irreps (A, B) and for groups where complex mode does not
        apply, each irrep gives a single real-valued row using the `fmt`
        formatter.
        """
        rows: list[tuple[str, list[str]]] = []
        use_complex = complex and _is_pure_cyclic()
        n = _group_order_n() if use_complex else None

        for irrep, char_row in zip(self._irreps, self._characters):
            label = _safe(irrep.name)
            is_e_type = (irrep.mulliken == IrrepLabel.Mulliken.E)

            if use_complex and is_e_type and n is not None:
                # Recover j (the E-type index) from the irrep subscript.
                # E1 → j=1, E2 → j=2, …  A single E with no subscript → j=1.
                sub = irrep.subscript
                j = sub if sub else 1
                row_top: list[str] = ["1"]   # χ(E) = 1 for both conjugate rows
                row_bot: list[str] = ["1"]
                for rc in self._unique_operations:
                    from ..operations.operation_label import OperationLabel as _OL
                    elem = rc.label.element
                    if elem in (_OL.Element.ProperRotation, _OL.Element.ImproperRotation):
                        k = rc.label.multiple or 1
                        d = rc.label.degree
                        # Convert (degree d, multiple k) to the S_n power index p.
                        # Each step of degree d corresponds to n/d steps of the
                        # fundamental S_n rotation, so p = k * (n // d).
                        p = k * (n // d) if n else k
                        row_top.append(_safe(_eps_symbol(j * p, n)))
                        row_bot.append(_safe(_eps_symbol(-j * p, n)))
                    else:
                        # Non-rotation columns (σ, i) have real characters;
                        # use the stored float value directly.
                        idx = self._unique_operations.index(rc)
                        row_top.append(fmt(char_row[idx + 1]))
                        row_bot.append(fmt(char_row[idx + 1]))
                rows.append((f"{label}{{", row_top))
                rows.append(("", row_bot))
            else:
                cells = [fmt(v) for v in char_row]
                rows.append((label, cells))
        return rows

    col_headers = ["E"] + [_safe(olc.short_name) for olc in self._unique_operations]
    name = _safe(self._label.name)
    data_rows = _build_rows()

    # ------------------------------------------------------------------
    # Basis function columns
    # ------------------------------------------------------------------
    from .basis_functions import compute_basis_functions
    try:
        bf = compute_basis_functions(self)
    except Exception:
        bf = {}

    lin_col: list[str] = []
    quad_col: list[str] = []
    for label, _ in data_rows:
        clean = label.rstrip("{").strip()
        lin_col.append(_safe(", ".join(bf.get(clean, {}).get("linear", []))))
        quad_col.append(_safe(", ".join(bf.get(clean, {}).get("quadratic", []))))

    has_bf = any(v for v in lin_col) or any(v for v in quad_col)

    # ------------------------------------------------------------------
    # Plain-text renderer
    # ------------------------------------------------------------------

    def _render_plain() -> None:
        """Render the character table as a plain-text fixed-width grid.

        Layout:
          - `row_w` : width of the leftmost "irrep label" column
          - `col_w` : list of widths, one per character table column
          - Each cell is right-aligned within its column width.
          - Columns are separated by " | ".
          - If basis functions were computed, two extra columns
            ("Lin/Rot" and "Quadratic") are appended.
        """
        row_labels = [r[0] for r in data_rows]
        row_w = max((len(r) for r in row_labels), default=4)

        def _col_width(h: str, col_vals: list[str]) -> int:
            """Return the display width for a column: max of header, its values, and a floor of 6."""
            # Minimum width of 6 ensures that values like "-1" and "2cos(Ï€/7)"
            # always fit without truncation and columns are never unreadably thin.
            return max(len(h), 6, max((len(v) for v in col_vals), default=1))

        col_w = [_col_width("E", [r[1][0] for r in data_rows])]
        for ci, h in enumerate(col_headers[1:], start=1):
            col_vals = [r[1][ci] if ci < len(r[1]) else "" for r in data_rows]
            col_w.append(_col_width(h, col_vals))

        extra_headers = (["Lin/Rot", "Quadratic"] if has_bf else [])
        lin_w  = max(len("Lin/Rot"),  max((len(v) for v in lin_col),  default=1)) if has_bf else 0
        quad_w = max(len("Quadratic"), max((len(v) for v in quad_col), default=1)) if has_bf else 0

        parts = [f"{name:{row_w}}"]
        for h, w in zip(col_headers, col_w):
            parts.append(f"{h:>{w}}")
        if has_bf:
            parts.append(f"{'Lin/Rot':>{lin_w}}")
            parts.append(f"{'Quadratic':>{quad_w}}")
        header = " | ".join(parts)
        print(header)
        print("-" * len(header))

        for (rl, cells), lv, qv in zip(data_rows, lin_col, quad_col):
            row_parts = [f"{rl:{row_w}}"]
            for ci, w in enumerate(col_w):
                row_parts.append(f"{(cells[ci] if ci < len(cells) else ''):>{w}}")
            if has_bf:
                row_parts.append(f"{lv:>{lin_w}}")
                row_parts.append(f"{qv:>{quad_w}}")
            print(" | ".join(row_parts))

    # ------------------------------------------------------------------
    # Rich renderer
    # ------------------------------------------------------------------

    def _render_rich() -> None:
        """Render the character table using the `rich` library for a
        cleaner, terminal-width-aware layout with aligned columns and
        styled headers.

        `rich` is an optional dependency.  If it is not installed, this
        function raises ImportError and the caller falls back to
        `_render_plain()`.  The `box.SIMPLE_HEAVY` style draws a heavy
        header separator and no outer border, which suits dense tabular data.
        """
        from rich.table import Table
        from rich import box
        from rich.console import Console

        table = Table(
            title=name,
            box=box.SIMPLE_HEAVY,
            show_header=True,
            header_style="bold",
            title_style="bold cyan",
        )
        table.add_column("Irrep", style="bold", no_wrap=True)
        for h in col_headers:
            table.add_column(h, justify="right", no_wrap=True)
        if has_bf:
            table.add_column("Lin / Rot", justify="left", style="dim")
            table.add_column("Quadratic", justify="left", style="dim")

        for (rl, cells), lv, qv in zip(data_rows, lin_col, quad_col):
            row = [rl] + [cells[i] if i < len(cells) else "" for i in range(len(col_headers))]
            if has_bf:
                row += [lv, qv]
            table.add_row(*row)

        Console().print(table)

    # ------------------------------------------------------------------
    # Dispatch
    # ------------------------------------------------------------------

    if plain:
        _render_plain()
        return

    try:
        _render_rich()
    except ImportError:
        _render_plain()

compute_basis_functions

compute_basis_functions(
    pg: PointGroup,
) -> dict[str, dict[str, list[str]]]

Return basis function assignments for all irreps of pg.

Returns:

Type Description
dict irrep_name → {"linear": [...], "quadratic": [...]}
Source code in pyrrhotite/point_groups/basis_functions.py
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
def compute_basis_functions(pg: PointGroup) -> dict[str, dict[str, list[str]]]:
    """Return basis function assignments for all irreps of *pg*.

    Returns
    -------
    dict  irrep_name → {"linear": [...], "quadratic": [...]}
    """
    lbl = pg.label
    # Only valid for axial groups
    if lbl.is_polyhedral() or lbl.is_linear() or lbl.group_class.name in ("C1", "Ci", "Cs"):
        return {}

    chars = pg.characters
    irreps = pg.irreps
    unique_ops = pg.unique_operations
    order = pg.order

    if not chars or order == 0:
        return {}

    # Build column metadata: one entry per character-table column (including E).
    # Each entry is (op_type, theta, count) so we can call _chi_basis_for_op
    # and weight each term by count in the reduction formula.
    col_meta: list[tuple[str, float, int]] = [("E", 0.0, 1)]  # identity always first
    for olc in unique_ops:
        op_type, theta = _classify_col(olc)
        col_meta.append((op_type, theta, olc.count))

    counts = [cm[2] for cm in col_meta]

    # For each of the 8 basis-function "sets" (z, (x,y), Rz, (Rx,Ry), z²,
    # (x²-y²,xy), (xz,yz), x²+y²), build the vector of characters under
    # every column.  This is the χ_Γ(c) vector needed by _reduce.
    keys = ("z", "xy", "Rz", "Rxy", "z2", "x2y2xy", "xzyz", "x2py2")
    sets: dict[str, list[float]] = {k: [] for k in keys}
    for op_type, theta, _ in col_meta:
        chi = _chi_basis_for_op(op_type, theta)
        for k in keys:
            sets[k].append(chi[k])

    # Apply the reduction formula to every basis-function set.
    # multiplicities[key][i] ≈ integer: how many times irrep i appears in set key.
    multiplicities = {k: _reduce(v, chars, counts, order) for k, v in sets.items()}

    result: dict[str, dict[str, list[str]]] = {
        ir.name: {"linear": [], "quadratic": []} for ir in irreps
    }

    def _assign(key: str, labels: list[str], category: str) -> None:
        """Attach display `labels` (under `category`) to every irrep that contains basis set `key`."""
        # For each irrep, if it appears at least once in this basis-function set
        # (multiplicity ≥ 1 after rounding), add the display labels to that irrep's
        # entry.  round() corrects for tiny floating-point errors in the sum.
        for i, ir in enumerate(irreps):
            mults = multiplicities[key]
            if i < len(mults) and round(mults[i]) >= 1:
                result[ir.name][category].extend(labels)

    # Map each computed basis-function set to its display label(s).
    # "linear" = translational and rotational functions (x, y, z, Rx, Ry, Rz)
    # "quadratic" = products of two coordinates (x², xy, xz, …)
    _assign("z",      ["z"],            "linear")
    _assign("xy",     ["x", "y"],       "linear")
    _assign("Rz",     ["Rz"],           "linear")
    _assign("Rxy",    ["Rx", "Ry"],     "linear")
    _assign("z2",     ["z²"],           "quadratic")
    _assign("x2py2",  ["x²+y²"],        "quadratic")
    _assign("x2y2xy", ["x²-y²", "xy"],  "quadratic")
    _assign("xzyz",   ["xz", "yz"],     "quadratic")

    return result

Element data

get_element module-attribute

get_element = element

get_atomic_number module-attribute

get_atomic_number = atomic_number

Element dataclass

Element(
    symbol: str,
    name: str,
    radius: float,
    mass: float,
    colour: tuple[float, float, float],
)

Represents a chemical element with display and physical properties.

Fields

symbol : str Standard chemical symbol, e.g. "C" for carbon. name : str Full element name, e.g. "carbon". radius : float Covalent radius in Ångströms (1 Å = 1e-10 m). Used to decide whether two atoms are close enough to be considered bonded — see Structure.calculate_bond_pairs(). Hydrogen is unusually small (0.25 Å); most main-group elements sit near 0.4–0.8 Å; metals are ~1.2 Å. mass : float Atomic mass in unified atomic mass units (u ≈ 1.66054e-27 kg). Used to compute the centre of mass when centring the molecule. colour : tuple[float, float, float] RGB display colour in the range [0, 1]. Follows the CPK colouring convention (white for H, grey for C, red for O, blue for N, etc.).


Display helpers

Convenience pretty-printers for exploring results in a shell or notebook. They take objects already produced by Structure, Symmetry, or PointGroup and print them in a readable form — everything they show is also reachable directly from those objects' attributes. They live under the pyrrhotite.display namespace.

print_bond_pairs

print_bond_pairs(s: Structure) -> None

Print every bonded atom pair with element symbols and atom indices.

Source code in pyrrhotite/display.py
35
36
37
38
39
40
def print_bond_pairs(s: Structure) -> None:
    """Print every bonded atom pair with element symbols and atom indices."""
    for a, b in s.calculate_bond_pairs():
        ea = element(int(s.atomic_numbers[a])).symbol
        eb = element(int(s.atomic_numbers[b])).symbol
        print(f"  {ea}{a} — {eb}{b}")

print_ops_with_atoms

print_ops_with_atoms(
    ops: list[Operation], s: Structure
) -> None

Print each symmetry operation and the atoms that lie on its axis or plane.

Source code in pyrrhotite/display.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def print_ops_with_atoms(ops: list[Operation], s: Structure) -> None:
    """Print each symmetry operation and the atoms that lie on its axis or plane."""
    for op in ops:
        lbl = op.label
        if lbl.element == OperationLabel.Element.Reflection:
            atom_indices = op.atoms_in_plane(s)
            loc = "in plane" + (" [molecular plane]" if op.is_molecular_plane(s) else "")
        else:
            atom_indices = op.atoms_on_axis(s)
            loc = "on axis"
        atoms = ", ".join(
            f"{element(int(s.atomic_numbers[i])).symbol}{i}" for i in atom_indices
        ) or "none"
        print(f"  {lbl.short_name:<10}  {loc}: {atoms}")

print_basis_functions

print_basis_functions(pg: PointGroup) -> None

Print the irrep → linear/rotational and quadratic basis function table.

Source code in pyrrhotite/display.py
59
60
61
62
63
64
65
66
67
def print_basis_functions(pg: PointGroup) -> None:
    """Print the irrep → linear/rotational and quadratic basis function table."""
    bf = compute_basis_functions(pg)
    print(f"  {'Irrep':<8}  {'Linear / Rotational':<26}  Quadratic")
    print("  " + _SEP)
    for irrep_name, funcs in bf.items():
        lin  = ", ".join(funcs["linear"])    or "—"
        quad = ", ".join(funcs["quadratic"]) or "—"
        print(f"  {irrep_name:<8}  {lin:<26}  {quad}")

print_char_table_programmatic

print_char_table_programmatic(pg: PointGroup) -> None

Print the character table by directly accessing pg.irreps, pg.characters, and pg.unique_operations.

Source code in pyrrhotite/display.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def print_char_table_programmatic(pg: PointGroup) -> None:
    """Print the character table by directly accessing pg.irreps, pg.characters, and pg.unique_operations."""
    irreps = pg.irreps
    ops    = pg.unique_operations
    chars  = pg.characters
    print("Irreducible representations:")
    for ir in irreps:
        print(f"  {ir.name}")
    print("\nConjugacy classes (from unique_operations, excluding E):")
    for olc in ops:
        print(f"  {olc.short_name:<12}  count={olc.count}")
    print("\nFull character table (rows = irreps, columns = E then unique ops):")
    header = f"  {'':>6}" + f"  {'E':>6}" + "".join(f"  {o.label.short_name:>6}" for o in ops)
    print(header)
    for i, ir in enumerate(irreps):
        row = "  ".join(f"{v:6.3f}" for v in chars[i])
        print(f"  {ir.name:<6}  {row}")

Sample molecules

list_sample_molecules

list_sample_molecules() -> list[str]

Return a sorted list of names of the built-in sample molecules.

Each name corresponds to the stem of an XYZ file in the bundled pyrrhotite/sample_molecules/ directory and can be passed directly to :func:load_sample, :func:analyse_sample, :func:visualize_sample, or :func:show_character_table_sample.

Source code in pyrrhotite/display.py
114
115
116
117
118
119
120
121
122
def list_sample_molecules() -> list[str]:
    """Return a sorted list of names of the built-in sample molecules.

    Each name corresponds to the stem of an XYZ file in the bundled
    pyrrhotite/sample_molecules/ directory and can
    be passed directly to :func:`load_sample`, :func:`analyse_sample`,
    :func:`visualize_sample`, or :func:`show_character_table_sample`.
    """
    return sorted(p.stem for p in _samples_dir().glob("*.xyz"))

load_sample

load_sample(name: str | None = None) -> Structure

Load a sample molecule as a :class:~src.Structure.

Parameters:

Name Type Description Default
name str | None

Stem of the XYZ file (e.g. "benzene", "water"). If None a molecule is chosen at random.

None
Source code in pyrrhotite/display.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def load_sample(name: str | None = None) -> Structure:
    """Load a sample molecule as a :class:`~src.Structure`.

    Parameters
    ----------
    name:
        Stem of the XYZ file (e.g. ``"benzene"``, ``"water"``).
        If *None* a molecule is chosen at random.
    """
    samples = _samples_dir()
    if name is None:
        path = random.choice(list(samples.glob("*.xyz")))
    else:
        path = samples / f"{name}.xyz"
        if not path.is_file():
            available = ", ".join(list_sample_molecules())
            raise FileNotFoundError(
                f"No sample molecule named '{name}'. Available: {available}"
            )
    return Structure(str(path))

analyse_sample

analyse_sample(name: str | None = None) -> 'Symmetry'

Run the full symmetry-determination pipeline on a sample molecule and print the result.

Parameters:

Name Type Description Default
name str | None

Stem of the XYZ file (e.g. "ammonia"). If None a molecule is chosen at random.

None

Returns:

Type Description
Symmetry

The completed :class:~src.Symmetry object for further inspection.

Source code in pyrrhotite/display.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def analyse_sample(name: str | None = None) -> "Symmetry":  # noqa: F821
    """Run the full symmetry-determination pipeline on a sample molecule and print the result.

    Parameters
    ----------
    name:
        Stem of the XYZ file (e.g. ``"ammonia"``).
        If *None* a molecule is chosen at random.

    Returns
    -------
    Symmetry
        The completed :class:`~src.Symmetry` object for further inspection.
    """
    from .symmetry import Symmetry
    structure = load_sample(name)
    print(f"Molecule : {structure.description}")
    sym = Symmetry(structure)
    print(f"Point group : {sym.point_group.label.name}")
    print(f"Rotor class : {sym.rotor_class.name}")
    return sym

show_character_table_sample

show_character_table_sample(
    name: str | None = None,
) -> None

Print the character table for the point group of a sample molecule.

Parameters:

Name Type Description Default
name str | None

Stem of the XYZ file (e.g. "benzene"). If None a molecule is chosen at random.

None
Source code in pyrrhotite/display.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def show_character_table_sample(name: str | None = None) -> None:
    """Print the character table for the point group of a sample molecule.

    Parameters
    ----------
    name:
        Stem of the XYZ file (e.g. ``"benzene"``).
        If *None* a molecule is chosen at random.
    """
    from .character_tables import print_character_table_for
    from .symmetry import Symmetry
    structure = load_sample(name)
    print(f"Molecule : {structure.description}")
    sym = Symmetry(structure)
    group_name = sym.point_group.label.name
    print(f"Point group : {group_name}\n")
    print_character_table_for(group_name)

Visualization

Requires the vis extras

These open an interactive window and need pip install 'pyrrhotite[vis]'. See Getting Started → Optional extras.

visualize

visualize(
    structure: Structure, show_labels: bool = False
) -> None

Open an interactive 3-D viewer for structure (requires pip install 'pyrrhotite[vis]').

Parameters:

Name Type Description Default
show_labels bool

Overlay element symbols on each atom. Default is False.

False
Source code in pyrrhotite/__init__.py
19
20
21
22
23
24
25
26
27
28
def visualize(structure: "Structure", show_labels: bool = False) -> None:
    """Open an interactive 3-D viewer for *structure* (requires ``pip install 'pyrrhotite[vis]'``).

    Parameters
    ----------
    show_labels:
        Overlay element symbols on each atom. Default is ``False``.
    """
    from .visualizer import visualize as _vis
    _vis(structure, show_labels=show_labels)

visualize_idealized_structure

visualize_idealized_structure(
    point_group,
    radius: float = 1.0,
    height: float = 0.6,
    element: str = "F",
    show_labels: bool = False,
) -> None

Generate an idealized structure for point_group and open the 3-D viewer.

Equivalent to visualize(generate_idealized_structure(point_group, ...)), without writing the structure to an .xyz file first. Requires pip install 'pyrrhotite[vis]'.

Parameters:

Name Type Description Default
point_group

Either a PointGroupLabel or a name string (e.g. "C12v", "D9d") -- see generate_idealized_structure.

required
radius float

Forwarded to generate_idealized_structure.

1.0
height float

Forwarded to generate_idealized_structure.

1.0
element float

Forwarded to generate_idealized_structure.

1.0
show_labels bool

Overlay element symbols on each atom. Default is False.

False
Source code in pyrrhotite/__init__.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def visualize_idealized_structure(
    point_group,
    radius: float = 1.0,
    height: float = 0.6,
    element: str = "F",
    show_labels: bool = False,
) -> None:
    """Generate an idealized structure for *point_group* and open the 3-D viewer.

    Equivalent to ``visualize(generate_idealized_structure(point_group, ...))``,
    without writing the structure to an `.xyz` file first. Requires
    ``pip install 'pyrrhotite[vis]'``.

    Parameters
    ----------
    point_group:
        Either a `PointGroupLabel` or a name string (e.g. "C12v", "D9d") --
        see `generate_idealized_structure`.
    radius, height, element:
        Forwarded to `generate_idealized_structure`.
    show_labels:
        Overlay element symbols on each atom. Default is ``False``.
    """
    structure = generate_idealized_structure(point_group, radius=radius, height=height, element=element)
    visualize(structure, show_labels=show_labels)

visualize_sample

visualize_sample(name: str | None = None) -> None

Open the interactive 3-D viewer for a sample molecule.

Requires the visualizer optional dependency (pip install 'pyrrhotite[vis]').

Parameters:

Name Type Description Default
name str | None

Stem of the XYZ file (e.g. "buckminsterfullerene"). If None a molecule is chosen at random.

None
Source code in pyrrhotite/display.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def visualize_sample(name: str | None = None) -> None:
    """Open the interactive 3-D viewer for a sample molecule.

    Requires the visualizer optional dependency (``pip install 'pyrrhotite[vis]'``).

    Parameters
    ----------
    name:
        Stem of the XYZ file (e.g. ``"buckminsterfullerene"``).
        If *None* a molecule is chosen at random.
    """
    from .visualizer import visualize as _vis
    structure = load_sample(name)
    print(f"Visualising: {structure.description}")
    _vis(structure)

Next steps

  • User Guide


    These functions shown in context, with worked explanations and options.

    Open the User Guide

  • Glossary


    Definitions for the symmetry and point-group terms used in these signatures.

    Open the glossary

  • Algorithm & Supported Groups


    How detection works under the hood, and the full list of supported groups.

    How it works