Source code for crystod.mo_diagram_fragment

"""Two-fragment extended-Hueckel MO diagrams (crystod-mol --diagram
--ao-left/--ao-right without --pyscf).

Semi-quantitative sibling of :mod:`crystod.mo_diagram_pyscf`: the molecule is
split into two arbitrary submolecules by chemical formula, and all three
columns (left | molecule | right) are solved in ONE atomic-orbital space --
the single-zeta STO extended-Hueckel basis of the whole molecule, with
Wolfsberg-Helmholz off-diagonals over exact two-center overlap integrals.
A fragment's pre-bonding levels are the generalized eigenstates of its own
(H, S) sub-block: in extended Hueckel the sub-block of the molecular matrices
IS the isolated fragment (H_ij depends only on the two orbitals), so no
counterpoise/ghost machinery is needed and every projection uses the one
shared overlap matrix.

Irrep labels follow the PySCF engine's "solve first, label by characters"
strategy (the fragments need not be invariant under the full molecular point
group; labels are omitted when they are not), and the MO numbering counts the
omitted core shells like the single-center EHT diagram, so e.g. the first
drawn a1g of C6 is 2a1g on top of the 1a1g core.

References:
  M. Wolfsberg and L. Helmholz, J. Chem. Phys. 20, 837 (1952).
  R. Hoffmann, J. Chem. Phys. 39, 1397 (1963).
"""

from __future__ import annotations

import os
import re
from dataclasses import dataclass, field

import numpy as np

from .decompose_irrep import get_character_table
from .mo_diagram import (
    CORE_SHELLS,
    VALENCE_ELECTRONS,
    AtomicOrbital,
    _group_degenerate,
    _sketch_entries,
    build_basis,
    canonical_sketch_partners,
    diagram_geometry,
    element_color,
    hamiltonian_matrix,
    lowercase_irrep,
    overlap_matrix,
    render_diagram_page,
    svg_sub_digits,
)
from .mo_diagram_pyscf import (
    _atom_permutation,
    _format_formula,
    parse_fragment_spec,
)
from .molecular_salc import (
    _hm_symbol,
    _match_operations,
    _table_operations_cartesian,
    get_symmetry,
    load_molecule,
)
from .operations import wigner_D_real


@dataclass
class FragmentLevel:
    column: str
    energy: float                # eV
    degeneracy: int
    label: str
    electrons: int
    irrep: str | None
    level_id: str
    vectors: np.ndarray          # (n_ao, degeneracy), full AO space
    composition: list = field(default_factory=list)
    detail: str = ""


[docs] class EhtFragmentDiagram: """Extended-Hueckel MO diagram of a molecule split into two fragments. The engine behind ``crystod-mol --diagram --ao-left A --ao-right B`` without ``--pyscf``: the sibling of ``MODiagram`` for molecules without a single center, e.g. benzene as H6 and C6 or methanol as H4 and CO. Constructing the object runs the analysis: the point group is detected and the molecule aligned to the standard frame when it belongs to one of the 32 crystallographic groups; the atoms are partitioned by the two formulas; the extended-Hueckel matrices of the whole molecule are built once, and the pre-bonding levels of each fragment are the generalized eigenstates of its own ``(H, S)`` sub-block (in extended Hueckel the sub-block is the isolated fragment, so no ghost basis is needed); the molecular MOs are projected onto the fragment MOs through the shared overlap matrix for the correlation lines and compositions. Levels are labeled by the characters of their eigenvectors (a fragment need not be invariant under the full molecular group; unlabeled levels are numbered plainly), numbered with the core shells counted, and given a COOP bonding character. Args: xyz_path (str): Path of the molecule file in XYZ format. tolerance (float): Distance tolerance in Angstrom for the symmetry detection (``--tolerance``). left_spec (str): Formula of the left fragment (``--ao-left``), e.g. ``"H6"``: element symbols with optional counts. right_spec (str): Formula of the right fragment (``--ao-right``), e.g. ``"C6"``. Both formulas are required and together must account for every atom of the molecule. Attributes: xyz_path: The molecule file as given. formula: Hill formula of the molecule (``"C6H6"``). schoenflies: Schoenflies symbol of the point group. hm: Its Hermann-Mauguin symbol, or ``None`` for a non-crystallographic group. linear: Whether the molecule is linear. character_table: Character table of the point group, or ``None``. operations: Rotation matrices of the group in the standard frame (empty when ``hm`` is ``None``). operation_classes: Class label of every entry of ``operations``. symbols: Element symbol of every atom. coordinates: ``(n_atoms, 3)`` Cartesian coordinates in Angstrom. left: Site indices of the left fragment. right: Site indices of the right fragment. left_name: Formula of the left fragment (``"H6"``). right_name: Formula of the right fragment (``"C6"``). orbitals: The AO basis of the whole molecule (``AtomicOrbital`` list). S: AO overlap matrix. H: Extended-Hueckel Hamiltonian in eV. rows: AO indices of each column (``"left"``, ``"mo"``, ``"right"``). electron_counts: Valence electrons of each column. levels: ``FragmentLevel`` lists per column, energy ascending. Each level has ``energy`` (eV), ``degeneracy``, ``irrep``, ``label``, ``electrons``, ``vectors`` (``(n_ao, degeneracy)`` AO-space coefficients) and ``composition`` (pairs of a level id and its weight; for the molecular column the projection onto the fragment levels); the molecular levels also carry ``bond_character`` and ``overlap_population``, the fragment levels ``dominant_spec``, their dominant ``(element, shell)``. homo: Highest occupied molecular level (``None`` if none). lumo: Lowest unoccupied molecular level (``None`` if none). Raises: SystemExit: The file is missing, a fragment formula is absent or cannot be parsed, the two formulas do not partition the molecule, or an element has no extended-Hueckel parameters. Example: >>> from crystod import mol >>> from crystod.examples import example_path >>> diagram = mol.EhtFragmentDiagram( ... example_path("XYZ_NH3.xyz"), left_spec="H3", right_spec="N") >>> diagram.left_name, diagram.right_name, diagram.electron_counts ('H3', 'N', {'mo': 8, 'left': 3, 'right': 5}) >>> diagram.homo.label, diagram.lumo.label ('3a1', '2e') >>> diagram.write_html("MolOD_NH3_fragments.html") """ def __init__(self, xyz_path, tolerance=0.3, left_spec=None, right_spec=None): self.xyz_path = xyz_path molecule = load_molecule(xyz_path) self.symbols = [site.specie.symbol for site in molecule] self.coordinates = np.array([site.coords for site in molecule]) # ---- symmetry: align to the standard point-group frame when possible schoenflies, operations = get_symmetry(molecule, tolerance) self.schoenflies = schoenflies self.hm = _hm_symbol(schoenflies) self.linear = "*" in schoenflies self.character_table = None self.operations = [] self.operation_classes = [] if self.hm is not None: self.character_table = get_character_table(self.hm) table_ops, table_classes = _table_operations_cartesian( self.character_table ) alignment, matched = _match_operations( operations, table_ops, table_classes ) self.operations = [table_ops[i] for i in matched] self.operation_classes = [table_classes[i] for i in matched] self.coordinates = self.coordinates @ alignment.T self._symmetrize_coordinates(tolerance) # ---- fragments (explicit split only; the default center/ligand # architecture is the classic 4-column MODiagram) self._identify_fragments(left_spec, right_spec) self.formula = _format_formula( {s: self.symbols.count(s) for s in set(self.symbols)}, hill=True ) # ---- one EHT AO space for all three columns self.orbitals = build_basis(self.symbols, list(range(len(self.symbols)))) self.S = overlap_matrix(self.orbitals, self.coordinates) self.H = hamiltonian_matrix(self.orbitals, self.S) self.rows = { "mo": np.arange(len(self.orbitals)), "left": np.array([i for i, ao in enumerate(self.orbitals) if ao.atom in set(self.left)], dtype=int), "right": np.array([i for i, ao in enumerate(self.orbitals) if ao.atom in set(self.right)], dtype=int), } self.electron_counts = { "mo": sum(VALENCE_ELECTRONS[s] for s in self.symbols), "left": sum(VALENCE_ELECTRONS[self.symbols[i]] for i in self.left), "right": sum(VALENCE_ELECTRONS[self.symbols[i]] for i in self.right), } self._build_levels() self._link_levels() self._assign_bond_characters() # ---------------------------------------------------------------- setup def _symmetrize_coordinates(self, tolerance): from .molecular_salc import get_permutation_matrices permutations = get_permutation_matrices( self.operations, self.coordinates, tolerance ) symmetrized = np.zeros_like(self.coordinates) for rotation, permutation in zip(self.operations, permutations): symmetrized += (permutation.T @ self.coordinates) @ rotation self.coordinates = symmetrized / len(self.operations) def _identify_fragments(self, left_spec, right_spec): if left_spec is None or right_spec is None: raise SystemExit("ERROR: give both --ao-left and --ao-right.") left_counts = parse_fragment_spec(left_spec) right_counts = parse_fragment_spec(right_spec) totals: dict[str, int] = {} for symbol in self.symbols: totals[symbol] = totals.get(symbol, 0) + 1 combined = { element: left_counts.get(element, 0) + right_counts.get(element, 0) for element in set(left_counts) | set(right_counts) } if combined != totals: molecule_formula = " ".join( f"{k}{v}" for k, v in sorted(totals.items()) ) raise SystemExit( f"ERROR: --ao-left {left_spec} + --ao-right {right_spec} does " f"not partition the molecule ({molecule_formula})." ) remaining = dict(left_counts) self.left, self.right = [], [] for i, symbol in enumerate(self.symbols): if remaining.get(symbol, 0) > 0: self.left.append(i) remaining[symbol] -= 1 else: self.right.append(i) self.left_name = _format_formula(left_counts) self.right_name = _format_formula(right_counts) # ------------------------------------------------------------- symmetry def _valid_operations(self, column): """Table operations that map the column's atoms onto themselves (and the other fragment onto itself), with the atom permutations.""" inside = set(self.left if column == "left" else self.right if column == "right" else range(len(self.symbols))) tags = [(symbol, i in inside) for i, symbol in enumerate(self.symbols)] valid = [] for rotation, class_name in zip(self.operations, self.operation_classes): permutation = _atom_permutation(self.coordinates, tags, rotation) if permutation is not None: valid.append((rotation, class_name, permutation)) return valid def _ao_representation(self, rotation, permutation): """Matrix of the symmetry operation on the EHT AO basis (real orbitals in the wigner_D_real component order).""" n_ao = len(self.orbitals) gamma = np.zeros((n_ao, n_ao)) blocks: dict[int, np.ndarray] = {} index = {(ao.atom, ao.shell, ao.m): i for i, ao in enumerate(self.orbitals)} for i, ao in enumerate(self.orbitals): if ao.l not in blocks: blocks[ao.l] = wigner_D_real(ao.l, rotation) target_atom = permutation[ao.atom] D = blocks[ao.l] for m in range(2 * ao.l + 1): gamma[index[(target_atom, ao.shell, m)], i] = D[m, ao.m] return gamma def _irrep_labels(self, column, vectors_by_group): """Irrep label per degenerate group, or None when the fragment is not invariant under the full point group (or no table exists).""" if self.character_table is None: return None valid = self._valid_operations(column) if len(valid) != len(self.operations): return None rotation_list = list(self.character_table["rotation_list"]) table = self.character_table["character_table"] representatives = {} for rotation, class_name, permutation in valid: representatives.setdefault(class_name, (rotation, permutation)) gammas = { class_name: self._ao_representation(rotation, permutation) for class_name, (rotation, permutation) in representatives.items() } labels = [] for vectors in vectors_by_group: characters = [ float(np.trace(vectors.T @ self.S @ gammas[c] @ vectors)) for c in rotation_list ] best, best_error = None, 1e9 for irrep, chars in table.items(): row = [float(np.real(c)) for c in np.atleast_1d(chars)] error = max(abs(a - b) for a, b in zip(characters, row)) if error < best_error: best, best_error = irrep, error labels.append(best if best_error < 0.05 else None) return labels def _split_group_by_irrep(self, column, vectors): """Decompose an exactly degenerate unlabeled group with the irrep projectors P = (d/|G|) sum chi(g) Gamma(g). A diatomic fragment inside a low-symmetry molecule keeps its own higher symmetry (e.g. the CO pi pair of CH3OH under Cs): the pair is exactly degenerate, chi(E) = 2 matches no Cs irrep, and no energy tolerance can split it -- but the projectors can (a' + a''). Returns [(irrep, vectors)] or None when the decomposition fails.""" if self.character_table is None: return None valid = self._valid_operations(column) if len(valid) != len(self.operations): return None rotation_list = list(self.character_table["rotation_list"]) table = self.character_table["character_table"] e_index = rotation_list.index("E") gammas = [self._ao_representation(rotation, permutation) for rotation, _, permutation in valid] pieces = [] for irrep, chars in table.items(): row = {c: float(np.real(v)) for c, v in zip(rotation_list, np.atleast_1d(chars))} dimension = row["E"] if "E" in row else float( np.real(np.atleast_1d(chars)[e_index]) ) projector = sum( row[class_name] * gamma for (_, class_name, _), gamma in zip(valid, gammas) ) * (dimension / len(valid)) projected = projector @ vectors gram = projected.T @ self.S @ projected values, mixing = np.linalg.eigh(gram) keep = values > 1e-6 if np.any(keep): pieces.append((irrep, projected @ mixing[:, keep] / np.sqrt(values[keep]))) if sum(piece.shape[1] for _, piece in pieces) != vectors.shape[1]: return None return pieces def _core_counts(self, column): """Number of omitted core levels per irrep of this column (the EHT basis is valence-only; cores only shift the MO numbering, matching the all-electron PySCF labels).""" if self.character_table is None: return {} valid = self._valid_operations(column) if len(valid) != len(self.operations): return {} sites = (self.left if column == "left" else self.right if column == "right" else list(range(len(self.symbols)))) rotation_list = list(self.character_table["rotation_list"]) table = self.character_table["character_table"] class_size: dict[str, int] = {} for name in self.operation_classes: class_size[name] = class_size.get(name, 0) + 1 order = len(self.operations) counts: dict[str, int] = {} for element in sorted({self.symbols[i] for i in sites}): element_sites = {i for i in sites if self.symbols[i] == element} for core_shell in CORE_SHELLS.get(element, []): l = {"s": 0, "p": 1, "d": 2, "f": 3}[core_shell[-1]] # class characters of (site permutation) x (orbital rotation) characters = {} for rotation, class_name, permutation in valid: if class_name in characters: continue fixed = sum(1 for i in element_sites if permutation[i] == i) characters[class_name] = ( fixed * float(np.trace(wigner_D_real(l, rotation))) ) for irrep, chars in table.items(): row = [float(np.real(c)) for c in np.atleast_1d(chars)] # <chi, chi> = 2 for the physically-irreducible merged # conjugate pairs of the C3/C4/C6/S4/S6/C3h/C4h/C6h/T/Th # tables (one chi(E) = 2 row per pair): the multiplicity # of the PAIR is the raw projection divided by that norm row_norm = sum( class_size[c] * row[k] ** 2 for k, c in enumerate(rotation_list) ) / order n = sum( class_size[c] * characters[c] * row[k] for k, c in enumerate(rotation_list) ) / order / row_norm n = int(round(n)) if n: counts[irrep] = counts.get(irrep, 0) + n return counts # --------------------------------------------------------------- levels def _solve_column(self, column): """Generalized eigenstates of the column's (H, S) sub-block, embedded back into the full AO space (canonical orthogonalization).""" rows = self.rows[column] H = self.H[np.ix_(rows, rows)] S = self.S[np.ix_(rows, rows)] values, vectors = np.linalg.eigh(S) keep = values > 1e-8 if not np.all(keep): print(f"NOTE: dropped {int((~keep).sum())} near-dependent basis " f"combination(s) in the {column} block.") X = vectors[:, keep] / np.sqrt(values[keep]) energies, transformed = np.linalg.eigh(X.T @ H @ X) C = np.zeros((len(self.orbitals), len(energies))) C[rows] = X @ transformed return energies, C def _build_levels(self): self.levels: dict[str, list[FragmentLevel]] = {} for column in ("left", "mo", "right"): energies, C = self._solve_column(column) groups = _group_degenerate(energies, tol=1e-4) labels = self._irrep_labels( column, [C[:, group] for group in groups] ) if labels is not None: # an accidental near-degeneracy can merge different irreps # into one unlabeled group: re-split those finely and relabel refined = [] for group, label in zip(groups, labels): if label is None and len(group) > 1: subgroups = _group_degenerate(energies[group], tol=1e-8) refined.extend([[group[i] for i in sub] for sub in subgroups]) else: refined.append(group) if len(refined) != len(groups): groups = sorted(refined, key=lambda g: float(energies[g[0]])) labels = self._irrep_labels( column, [C[:, group] for group in groups] ) # (energy, vectors, irrep) entries; exactly degenerate unlabeled # groups (a higher-symmetry fragment inside a lower-symmetry # molecule) are decomposed with the irrep projectors entries = [] for g_index, group in enumerate(groups): irrep = labels[g_index] if labels else None vectors = C[:, group] energy = float(np.mean(energies[group])) if labels is not None and irrep is None and len(group) > 1: split = self._split_group_by_irrep(column, vectors) if split is not None: entries.extend( (energy, piece, piece_irrep) for piece_irrep, piece in split ) continue entries.append((energy, vectors, irrep)) counters = dict(self._core_counts(column)) column_levels = [] electrons_left = self.electron_counts[column] for g_index, (energy, vectors, irrep) in enumerate(entries): if irrep is not None: counters[irrep] = counters.get(irrep, 0) + 1 label = f"{counters[irrep]}{lowercase_irrep(irrep)}" else: label = f"{g_index + 1}" electrons = int(min(electrons_left, 2 * vectors.shape[1])) electrons_left -= electrons column_levels.append(FragmentLevel( column=column, energy=energy, degeneracy=vectors.shape[1], label=label, electrons=electrons, irrep=irrep, level_id=f"{column}_{g_index}", vectors=vectors, )) self.levels[column] = column_levels occupied = [l for l in self.levels["mo"] if l.electrons > 0] empty = [l for l in self.levels["mo"] if l.electrons == 0] self.homo = occupied[-1] if occupied else None self.lumo = empty[0] if empty else None def _link_levels(self): """Project the molecule MOs onto the fragment MOs (same AO space).""" for level in self.levels["mo"]: weights = [] for column in ("left", "right"): for fragment_level in self.levels[column]: P = fragment_level.vectors.T @ self.S @ level.vectors weight = float(np.sum(P ** 2)) if weight > 1e-6: weights.append((fragment_level, weight)) total = sum(w for _, w in weights) or 1.0 level.composition = [ (fragment_level.level_id, weight / total) for fragment_level, weight in weights ] names = { level.level_id: f"{self._display_name(level.column)} {level.label}" for column_levels in self.levels.values() for level in column_levels } for column_levels in self.levels.values(): for level in column_levels: parts = ", ".join( f"{100 * w:.0f}% {names[i]}" for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.02 ) level.detail = ( f"{level.label}: E = {level.energy:.2f} eV, " f"{level.electrons} e-" + (f" | {parts}" if parts else "") ) def _assign_bond_characters(self): """COOP bonding character via the shared crystal-engine classifier (crystal_orbital_diagram.assign_bond_characters); fragment levels are also tagged with their dominant (element, shell) for the VESTA line colors, like the PySCF engine.""" from types import SimpleNamespace from .crystal_orbital_diagram import assign_bond_characters values, vectors = np.linalg.eigh(self.S) sqrt_overlap = ( vectors * np.sqrt(np.clip(values, 0.0, None)) ) @ vectors.T spec_lists: dict[tuple[str, str], list[int]] = {} for i, ao in enumerate(self.orbitals): spec_lists.setdefault((ao.element, ao.shell), []).append(i) spec_ranges = { key: np.array(indices, dtype=int) for key, indices in spec_lists.items() } proxy_levels: dict[str, list] = {} for column in ("left", "right"): row_set = set(self.rows[column].tolist()) side_ranges = { key: np.array([i for i in indices if i in row_set], dtype=int) for key, indices in spec_ranges.items() if any(i in row_set for i in indices) } proxies = [] for level in self.levels[column]: gross = (np.abs(sqrt_overlap @ level.vectors) ** 2).sum(axis=1) dominant = max( side_ranges, key=lambda key: float(gross[side_ranges[key]].sum()), ) level.dominant_spec = dominant proxies.append(SimpleNamespace( label=f"{dominant[0]} {dominant[1]} {level.label}", energy=level.energy, electrons=level.electrons, )) proxy_levels[column] = proxies proxy_levels["mo"] = [ SimpleNamespace( vectors=level.vectors, degeneracy=level.degeneracy, electrons=level.electrons, energy=level.energy, label=level.label, detail="", ) for level in self.levels["mo"] ] assign_bond_characters( proxy_levels, self.S, self.rows["left"], self.rows["right"], spec_ranges, sqrt_overlap=sqrt_overlap, ) for level, proxy in zip(self.levels["mo"], proxy_levels["mo"]): level.bond_character = proxy.bond_character level.overlap_population = proxy.overlap_population level.detail += proxy.detail # --------------------------------------------------------------- output def _column_name(self, column): return {"left": self.left_name, "mo": self.formula, "right": self.right_name}[column] def _display_name(self, column): name = self._column_name(column) if column != "mo" and self.left_name == self.right_name: return f"{name}({'L' if column == 'left' else 'R'})" return name def _sketch_partners(self, level): """Per-atom orbital amplitudes of every partner (minimal STO basis: the bare coefficients are the drawn lobes, like the single-center EHT diagram; 9 slots when the basis carries d orbitals).""" has_d = any(ao.l == 2 for ao in self.orbitals) width = 9 if has_d else 4 slot_of = {0: 0, 1: 1, 2: 4} partners = [] for k in range(level.vectors.shape[1]): vector = level.vectors[:, k] per_atom: dict[int, list[float]] = {} for i, ao in enumerate(self.orbitals): if ao.l > 2 or abs(vector[i]) < 1e-10: continue values = per_atom.setdefault(ao.atom, [0.0] * width) values[slot_of[ao.l] + ao.m] += float(vector[i]) partners.append(per_atom) if not has_d: partners = canonical_sketch_partners(partners) return [_sketch_entries(per_atom) for per_atom in partners]
[docs] def print_report(self): """Print the text report of the two-fragment diagram to stdout. Sections: molecule and point group, the two fragments with their atoms and valence-electron counts, the molecular orbitals up to 12 eV above the LUMO (energy, occupation, composition in fragment levels), and the electron filling with HOMO, LUMO and gap. """ print("\n* Molecule *") print(f"{self.xyz_path} ({self.formula}, {len(self.symbols)} atoms)") print("\n* Point group *") print(self.schoenflies + (f" ({self.hm})" if self.hm else "")) print(f"\n* Fragments ({self.left_name} | {self.right_name}) *") for name, sites in (("left", self.left), ("right", self.right)): atoms = " ".join(f"{self.symbols[i]}{i}" for i in sites) print(f"{self._display_name(name):>8s}: {atoms} " f"({self.electron_counts[name]} valence e-)") print("\n* Molecular orbitals (extended Hueckel, Wolfsberg-Helmholz " "K = 1.75) *") names = { level.level_id: f"{self._display_name(level.column)} {level.label}" for column_levels in self.levels.values() for level in column_levels } print(f"{'MO':>7s} {'E (eV)':>10s} {'occ':>4s} composition") ceiling = self.lumo.energy + 12.0 if self.lumo else np.inf for level in reversed(self.levels["mo"]): if level.energy > ceiling: continue parts = ", ".join( f"{100 * w:.0f}% {names[i]}" for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.02 ) tag = f"{level.label} x{level.degeneracy}" if level.degeneracy > 1 \ else level.label print(f"{tag:>7s} {level.energy:10.2f} {level.electrons:4d} {parts}") filling = " ".join( f"({level.label})^{level.electrons}" for level in self.levels["mo"] if level.electrons ) print(f"\n* Electron filling ({self.electron_counts['mo']} valence " "electrons) *") print(filling) if self.homo and self.lumo: print(f"HOMO = {self.homo.label} ({self.homo.energy:.2f} eV), " f"LUMO = {self.lumo.label} ({self.lumo.energy:.2f} eV), " f"gap = {self.lumo.energy - self.homo.energy:.2f} eV") print("\nMethod: two-fragment symmetry + overlap MO diagram " "(extended Hueckel)")
[docs] def write_html(self, output_path): """Write the interactive three-column HTML diagram. Columns: left-fragment MOs, molecule MOs, right-fragment MOs, with correlation lines weighted by the projections, electron arrows, HOMO/LUMO marks, an adjustable energy window, per-level details and the orbital sketch viewer. Args: output_path (str): Path of the HTML file to write (``crystod-mol`` uses ``MolOD_{molecule}.html`` by default). """ columns = {"left": 200, "mo": 480, "right": 760} half = {"left": 34, "mo": 34, "right": 34} order = ["left", "mo", "right"] side = {"left": -1, "mo": 1, "right": 1} headers = { column: f"{svg_sub_digits(self._display_name(column))} MOs" for column in order } names = { other.level_id: f"{self._display_name(other.column)} {other.label}" for levels in self.levels.values() for other in levels } bond_letter = {"bonding": "b", "nonbonding": "n", "antibonding": "a"} levels_json = [] for column_levels in self.levels.values(): for level in column_levels: character = getattr(level, "bond_character", None) spec = getattr(level, "dominant_spec", None) elc = (element_color(spec[0]) if level.column != "mo" and spec else None) levels_json.append({ **({"elc": elc} if elc else {}), "id": level.level_id, "col": level.column, "e": round(level.energy, 4), "deg": level.degeneracy, **({"bond": bond_letter[character]} if character else {}), "label": level.label, "el": level.electrons, "occ": level.electrons > 0, "links": [ [i, round(w, 4)] for i, w in level.composition if w >= 0.02 ], "comp": [ [names[i], round(100 * w, 1)] for i, w in sorted(level.composition, key=lambda kv: -kv[1]) if w > 0.005 ], "detail": level.detail, "orb": self._sketch_partners(level) or None, }) all_energies = [ level.energy for column_levels in self.levels.values() for level in column_levels ] e_min = min(all_energies) - 3.0 e_max = (self.lumo.energy + 8.0) if self.lumo else max(all_energies) e_max = min(e_max, max(all_energies) + 2.0) formula_html = re.sub(r"(\d+)", r"<sub>\1</sub>", self.formula) gap = "" if self.homo and self.lumo: gap = (f"HOMO&ndash;LUMO gap " f"{self.lumo.energy - self.homo.energy:.2f} eV") chips = [ formula_html, f"{self.schoenflies}" + (f" ({self.hm})" if self.hm else ""), f"{self.left_name} + {self.right_name}", "extended H&uuml;ckel / STO overlaps", gap, ] render_diagram_page( output_path, title=f"MO diagram: {self.formula} ({self.left_name} + " f"{self.right_name})", heading_html=f"Molecular-orbital diagram: {formula_html} " f"<span style=\"color:#90a4ae;font-size:14px\">" f"{self.left_name} + {self.right_name}, " f"extended H&uuml;ckel</span>", chips=chips, columns=columns, half=half, order=order, side=side, headers=headers, levels_json=levels_json, homo_id=self.homo.level_id if self.homo else None, lumo_id=self.lumo.level_id if self.lumo else None, e_min=e_min, e_max=e_max, geometry=diagram_geometry(self.symbols, self.coordinates), foot_html=( "Semi-quantitative two-fragment diagram from symmetry + " "overlap only (extended H&uuml;ckel, Wolfsberg&ndash;Helmholz " "K = 1.75, single-&zeta; STOs, exact two-center overlap " "integrals; all three columns solved in the one molecular AO " "space, fragment levels from the fragment's own (H, S) " "sub-block, molecule MOs projected onto them through the " "shared overlap matrix). Valence shells only &mdash; core " "shells enter the MO numbering, not the diagram." + (" MO line colors: " "<span style=\"color:#1565c0\">bonding</span> / " "<span style=\"color:#333\">nonbonding</span> / " "<span style=\"color:#d32f2f\">antibonding</span>, from " "the left&ndash;right overlap population 2 Re " "c<sub>L</sub>&#8224;S c<sub>R</sub> of each state " "(COOP-style; value in the level's tooltip)." if any(getattr(level, "bond_character", None) for level in self.levels["mo"]) else "") + " Generated by CrystOD (crystod-mol --diagram " "--ao-left/--ao-right)." ), )
def run_fragment_diagram(args) -> None: diagram = EhtFragmentDiagram( args.xyz, args.tolerance, left_spec=args.ao_left, right_spec=args.ao_right, ) diagram.print_report() stem = os.path.splitext(os.path.basename(args.xyz))[0] output_path = args.output or f"MolOD_{stem}.html" diagram.write_html(output_path) print(f"\nMO diagram written to {output_path}")