Source code for vibeqc.output.formats.scf_log

"""Formatters and log emitters for SCF trace output.

The native RHF driver populates ``RHFResult.scf_trace`` (a list of
``SCFIteration`` records) on every run. This module turns that trace into
either:

- a formatted multi-line string (``format_scf_trace``), or
- a stream of structured log records via the standard ``logging`` module
  (``log_scf_trace``).

By convention, log records go to the ``vibeqc.scf`` logger. A library-level
``NullHandler`` is attached in ``vibeqc.__init__`` so nothing is emitted
unless the user explicitly configures logging -- consistent with how
PySCF, numpy, and every other well-behaved Python library behave.

Typical usage::

    import logging
    logging.basicConfig(level=logging.INFO)   # summary lines only
    # or:
    logging.getLogger("vibeqc.scf").setLevel(logging.DEBUG)  # per-iter too

    result = run_rhf(mol, basis)
    log_scf_trace(result)
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Tuple, Union

import numpy as np

from ...banner import banner
from ..channel import Level
from ..channel import write as _channel_write
from ..document import (
    Column,
    HeaderlessBlock,
    HeaderlessColumn,
    Quantity,
    Table,
    active_policy,
    render_energy,
)

try:
    # Imported lazily because properties.py pulls in the compiled core;
    # when vibeqc.scf_log is imported during test-harness probing of
    # vibeqc.banner in isolation, avoid the cascade. In normal use this
    # always succeeds.
    from ...properties import (
        dipole_moment,
        hirshfeld_charges,
        loewdin_charges,
        mayer_bond_orders,
        mulliken_charges,
    )
    from ...properties import prominent_bonds as _prominent_bonds

    _PROPERTIES_AVAILABLE = True
except Exception:
    _PROPERTIES_AVAILABLE = False

if TYPE_CHECKING:  # pragma: no cover - import guard
    from ..._vibeqc_core import RHFResult  # noqa: F401

_DEFAULT_LOGGER_NAME = "vibeqc.scf"

# Column layout:
#   iter(>=3)  energy(18.10f)  dE(+.3e)  ||grad||(.3e)  diis(2d)
_HEADER = "  iter     energy (Ha)            dE          ||[F,DS]||   DIIS"
_SEPARATOR = "  " + "-" * (len(_HEADER) - 2)

[docs] def format_scf_trace( result, *, molecule=None, basis=None, include_banner: bool = True, include_properties: bool = True, n_virtual: int = 5, property_status: Optional[dict] = None, energy_label: str = "Total energy", ) -> str: """Return a multi-line string with a header, per-iteration lines and a summary. Safe to print, log, or write to a file. Parameters ---------- result Any SCF result struct with a ``scf_trace`` list and ``converged``, ``n_iter``, ``energy`` fields (RHFResult, UHFResult, RKSResult, UKSResult, PeriodicRHFResult, PeriodicKSResult, ...). molecule Optional :class:`Molecule`. When supplied, the trace is extended with an energy-component breakdown (DFT only), an orbital-energy table, and HOMO/LUMO markers. Without it we can't infer occupation counts from the result alone, so those sections are skipped. basis Optional :class:`BasisSet`. Required alongside ``molecule`` to compute the post-SCF properties block (Mulliken / Löwdin charges, Mayer bond orders, dipole moment). When absent, the properties block is skipped. include_banner Prepend the vibe-qc version banner. Default ``True``; set ``False`` to produce a trace without provenance (e.g. for unit-test comparisons where the version string would drift). include_properties Emit the Mulliken / Löwdin / Mayer / dipole block when molecule and basis are both supplied. Default ``True``. n_virtual Number of virtual orbitals above HOMO to print in the table. Default 5. property_status Optional mutable dict; when supplied, the post-SCF properties block records per-analysis success into it (currently ``property_status["hirshfeld"]``). Lets a caller gate a citation on whether a best-effort analysis actually computed, without recomputing it. Default ``None`` (no status reported). energy_label Label for the scalar energy in the component breakdown. ``run_job`` uses ``"SCF energy"`` when post-SCF add-ons such as dispersion are requested, so parsers do not mistake the SCF trace for the final user-facing total. """ segments = _scf_trace_segments( result, molecule=molecule, basis=basis, include_banner=include_banner, include_properties=include_properties, n_virtual=n_virtual, property_status=property_status, energy_label=energy_label, ) # Each segment carries the separator that precedes it (default one blank # line, "\n\n"); the convergence verdict rejoins its table with a single # "\n", so concatenating reproduces the historical string exactly. if not segments: return "" parts = [_seg_parts(segments[0])[0]] for seg in segments[1:]: text, _level, sep = _seg_parts(seg) parts.append(sep + text) return "".join(parts)
# Segment = one logical block of the SCF trace, the verbosity band it # belongs to, and (optionally) the separator that precedes it. A 2-tuple # ``(text, level)`` takes the default ``"\n\n"`` separator; a 3-tuple # ``(text, level, sep)`` overrides it (the convergence verdict uses ``"\n"`` # to stay welded to its table byte-for-byte while carrying its own level). # The structure lets a block move to QUIET / VERBOSE / DEBUG in exactly one # place (its entry here) and ``write_scf_trace`` will then filter it. Segment = Union[Tuple[str, Level], Tuple[str, Level, str]] def _seg_parts(seg: Segment) -> Tuple[str, Level, str]: """Unpack a segment into ``(text, level, sep)``, defaulting ``sep`` to the historical one-blank-line ``"\\n\\n"`` when a 2-tuple is given.""" text, level = seg[0], seg[1] sep = seg[2] if len(seg) > 2 else "\n\n" return text, level, sep def _scf_trace_segments( result, *, molecule=None, basis=None, include_banner: bool = True, include_properties: bool = True, n_virtual: int = 5, property_status: Optional[dict] = None, energy_label: str = "Total energy", ) -> List[Segment]: """Build the SCF trace as ``(block_text, level)`` segments. Concatenating the block texts with ``"\\n\\n"`` reproduces :func:`format_scf_trace`'s string byte-for-byte; :func:`write_scf_trace` instead emits each block at its own level so a block can be gated to ``VERBOSE`` / ``DEBUG`` without touching the others. Assign a block's band by changing the ``Level`` on its ``segments.append(...)`` below. """ segments: List[Segment] = [] if include_banner: segments.append((banner(), Level.STANDARD)) trace_lines = [_HEADER, _SEPARATOR] for step in result.scf_trace: trace_lines.append(_format_iter_line(step)) trace_lines.append(_SEPARATOR) segments.append(("\n".join(trace_lines), Level.STANDARD)) # The convergence verdict + final energy is *the* essential result, so # it is tagged QUIET -- it survives VIBEQC_OUTPUT_LEVEL=quiet even when # the iteration table above it (STANDARD) is filtered out. It rejoins # that table with a single "\n" separator, so format_scf_trace stays # byte-identical to when the two were one segment. status = "converged" if result.converged else "NOT converged" conv_line = ( f" {status} in {result.n_iter} iterations; " f"E = {render_energy(result.energy, width=0)} {active_policy().unit_of('energy')}" ) segments.append((conv_line, Level.QUIET, "\n")) if molecule is None: components = _format_energy_components(result, energy_label=energy_label) if components: segments.append((components, Level.STANDARD)) if molecule is not None: orb = _format_orbital_sections( result, molecule, n_virtual=n_virtual, energy_label=energy_label ) if orb: segments.append((orb, Level.STANDARD)) if ( include_properties and molecule is not None and basis is not None and _PROPERTIES_AVAILABLE ): props = _format_properties_block( result, molecule, basis, status=property_status ) if props: segments.append((props, Level.STANDARD)) return segments def write_scf_trace( result, *, write: Optional[Callable[..., object]] = None, trailing: str = "", molecule=None, basis=None, include_banner: bool = True, include_properties: bool = True, n_virtual: int = 5, property_status: Optional[dict] = None, energy_label: str = "Total energy", ) -> None: """Emit the SCF trace block-by-block, each block at its own level. The level-aware counterpart to :func:`format_scf_trace`: instead of returning one string, it writes each segment through ``write`` (the ambient :func:`vibeqc.output.write` by default) tagged with the segment's :class:`Level`. With every segment at ``STANDARD`` -- the state today -- the emitted bytes are identical to ``write(format_scf_trace(...) + trailing)``; moving a segment to ``VERBOSE`` / ``DEBUG`` makes that block disappear on a lower-threshold channel while the rest stay. ``trailing`` is written verbatim after the last block (the runners pass the ``"\\n"`` / ``"\\n\\n"`` they used to append to the string). Each block's leading separator (one blank line by default; ``"\\n"`` for the convergence verdict welded to its table) is written at *that block's* level, so a suppressed block takes its own leading gap with it rather than leaving a dangling one. Because the convergence verdict is tagged ``QUIET`` while the iteration table above it is ``STANDARD``, a ``QUIET`` channel keeps the final energy + converged/not verdict and drops the per-iteration table. """ emit = _channel_write if write is None else write segments = _scf_trace_segments( result, molecule=molecule, basis=basis, include_banner=include_banner, include_properties=include_properties, n_virtual=n_virtual, property_status=property_status, energy_label=energy_label, ) for i, seg in enumerate(segments): text, level, sep = _seg_parts(seg) if i > 0: emit(sep, level) emit(text, level) if trailing: emit(trailing, Level.STANDARD)
[docs] def log_scf_trace( result, logger: Optional[logging.Logger] = None, *, level_header: int = logging.INFO, level_iter: int = logging.DEBUG, level_summary: int = logging.INFO, ) -> None: """Emit the SCF trace through the ``logging`` module. Log levels are split so users can get summary-only (default INFO) or full per-iteration output (DEBUG) by setting the logger level. """ lg = logger if logger is not None else logging.getLogger(_DEFAULT_LOGGER_NAME) if lg.isEnabledFor(level_iter): lg.log(level_iter, _HEADER) lg.log(level_iter, _SEPARATOR) for step in result.scf_trace: lg.log(level_iter, _format_iter_line(step)) lg.log(level_iter, _SEPARATOR) status = "converged" if result.converged else "NOT CONVERGED" lg.log( level_summary, "SCF %s in %d iterations; E = %.10f Ha (= %.4f eV)", status, result.n_iter, result.energy, # 1 Hartree = 27.211386245988 eV (CODATA 2018) result.energy * 27.211386245988, )
def _step_get(step, name: str, default=0): """Read a trace field from either an ``SCFIteration`` object or a dict. The C++ drivers (RHF/UHF/RKS, GDF, BIPOLE) return ``SCFIteration`` structs; the GAPW multi-k Python driver appends plain dicts (``periodic_gapw_open_shell.py``) with the same field names but no ``diis_subspace`` key. Reading through this accessor lets the one formatter render both, so periodic results reach ``format_scf_trace`` without the runner needing a parallel hand-rolled table. """ if isinstance(step, dict): return step.get(name, default) return getattr(step, name, default) def _format_iter_line(step) -> str: it = _step_get(step, "iter") de = f"{_step_get(step, 'delta_e'):+.3e}" if it > 1 else " -- " diis_n = _step_get(step, "diis_subspace") diis = f"{diis_n:3d}" if diis_n > 0 else " -" return ( f" {it:4d} {_step_get(step, 'energy'):18.10f} {de} " f"{_step_get(step, 'grad_norm'):.3e} {diis}" ) def _format_orbital_sections( result, molecule, *, n_virtual: int, energy_label: str = "Total energy", ) -> str: """Energy-component breakdown (DFT only) + orbital-energy table. Returns an empty string if the result has neither ``e_xc`` nor ``mo_energies``/``mo_energies_alpha`` -- e.g. an MP2Result. """ sections: list[str] = [] components = _format_energy_components(result, energy_label=energy_label) if components: sections.append(components) n_electrons = molecule.n_electrons() multiplicity = molecule.multiplicity n_alpha = (n_electrons + multiplicity - 1) // 2 n_beta = n_electrons - n_alpha # ROHF/ROKS keep one spin-restricted orbital set (the alpha/beta # columns would be identical), so print a single table with the # closed/open/virtual occupations (2/1/0) instead of the UHF # two-column layout. _method = str(getattr(result, "method", "")).lower() if _method in ("rohf", "roks") and hasattr(result, "mo_occupations"): sections.append( _format_orbital_table_rohf( result, n_alpha=n_alpha, n_beta=n_beta, n_virtual=n_virtual ) ) elif hasattr(result, "mo_energies_alpha"): sections.append( _format_orbital_table_uhf( result, n_alpha=n_alpha, n_beta=n_beta, n_virtual=n_virtual, ) ) elif hasattr(result, "mo_energies"): sections.append( _format_orbital_table_rhf( result, n_occ=n_electrons // 2, n_virtual=n_virtual ) ) return "\n\n".join(s for s in sections if s) def _format_energy_components(result, *, energy_label: str = "Total energy") -> str: """Energy-component breakdown block. Supports two result shapes: * molecular / periodic KS results with scalar ``e_xc``-style fields; * BIPOLE periodic RHF results with a per-iteration ``energy_components`` history in CRYSTAL ``ENECYCLE`` terms. """ history = getattr(result, "energy_components", None) if history: comp = history[-1] rows: list[tuple[str, float]] = [ ("Nuclear repulsion", comp.e_nuclear_repulsion), ("Electronic", comp.e_electronic), ("Kinetic", comp.e_kinetic), ("Nuclear attraction", comp.e_nuclear_attraction), ] for label, attr in ( ("BIELET zone E-E", "e_bielet_zone_ee"), ("EXT EL-POLE", "e_ext_el_pole"), ("EXT EL-SPHEROPOLE", "e_ext_el_spheropole"), ("J short-range", "e_j_short_range"), ("J long-range", "e_j_long_range"), ("HF exchange", "e_exchange"), ): value = getattr(comp, attr, None) if value is not None: rows.append((label, value)) rows.extend( [ ("Two-electron (J+K)", comp.e_two_electron), (energy_label, comp.e_total), ] ) block = HeaderlessBlock( "Energy components", [ HeaderlessColumn("<", min_width=32), HeaderlessColumn(">", min_width=18), ], gutter=1, unit_for="energy", ) for label, value in rows: block.add_row(label, Quantity(value, "energy")) block.divider() return block.render(active_policy()) if not hasattr(result, "e_xc"): return "" rows: list[tuple[str, float]] = [] for label, attr in ( ("Nuclear repulsion", "e_nuclear"), ("Electronic", "e_electronic"), ("Coulomb (J)", "e_coulomb"), ("HF exchange (K)", "e_hf_exchange"), ("Exchange-correlation (XC)", "e_xc"), ("EXT EL-SPHEROPOLE", "e_ext_el_spheropole"), ): value = getattr(result, attr, None) if value is not None: rows.append((label, value)) # Dudarev DFT+U contribution. Only shown when non-zero, so a job with # no +U sites (every molecular job, most periodic jobs) is unchanged. _u = getattr(result, "e_dft_plus_u", None) if _u is not None and float(_u) != 0.0: rows.append(("DFT+U (Dudarev)", float(_u))) rows.append((energy_label, result.energy)) block = HeaderlessBlock( "Energy components", [ HeaderlessColumn("<", min_width=32), HeaderlessColumn(">", min_width=18), ], gutter=1, unit_for="energy", ) for label, value in rows: block.add_row(label, Quantity(value, "energy")) block.divider() return block.render(active_policy()) def _orbital_window( energies: Sequence[float], n_occ: int, n_virtual: int ) -> tuple[int, int]: """Slice bounds [start, end) to print: all occupied + up to n_virtual.""" start = 0 end = min(len(energies), n_occ + max(0, n_virtual)) return start, end def _orbital_table_lines( title: str, energies: Sequence[float], start: int, end: int, occ_of, marker_of, gap_line: Optional[str] = None, ) -> str: """The single definition of the ``#/occ/eps(Ha)/eps(eV)`` orbital table shared by the RHF / ROHF / UHF renderers. ``occ_of(i)`` and ``marker_of(i)`` supply each row's occupation and its HOMO / LUMO / SOMO / frontier marker; ``gap_line`` (when given) is appended after the rows. Keeping the header, the rule width, and the row format string in one place is why the orbital table has one format rather than four near-duplicates that can drift (the deliberate dual Ha/eV columns are intentional -- an orbital energy is universally quoted in both. Explicit unit overrides on :func:`render_energy` preserve that deliberate dual-unit presentation under the active format policy).""" block = HeaderlessBlock( title, [ HeaderlessColumn(">", min_width=4), HeaderlessColumn(">", min_width=4), HeaderlessColumn(">", min_width=16), HeaderlessColumn(">", min_width=14), ], gutter=2, annotation_gutter=2, ) block.add_row("#", "occ", "eps (Ha)", "eps (eV)").divider() for i in range(start, end): block.add_row( i + 1, f"{occ_of(i):.1f}", render_energy(energies[i], width=16, precision=8, unit="Ha"), render_energy(energies[i], width=14, precision=6, unit="eV"), annotation=marker_of(i).strip() or None, ) if gap_line is not None: block.footer(gap_line.removeprefix(" ")) return block.render(active_policy()) def _format_orbital_table_rhf(result, *, n_occ: int, n_virtual: int) -> str: energies = list(result.mo_energies) start, end = _orbital_window(energies, n_occ, n_virtual) def _marker(i: int) -> str: if i == n_occ - 1: return " <-- HOMO" if i == n_occ: return " <-- LUMO" return "" gap_line = None if 0 < n_occ < len(energies): gap = energies[n_occ] - energies[n_occ - 1] gap_line = ( " HOMO-LUMO gap: " f"{render_energy(gap, width=0, precision=6, unit='Ha')} Ha " f"({render_energy(gap, width=0, precision=4, unit='eV')} eV)" ) return _orbital_table_lines( "Molecular orbitals (Ha / eV)", energies, start, end, lambda i: 2.0 if i < n_occ else 0.0, _marker, gap_line, ) def _format_orbital_table_rohf( result, *, n_alpha: int, n_beta: int, n_virtual: int ) -> str: """Single-column orbital table for a restricted-open-shell result. Occupations are 2 (closed / doubly occupied), 1 (open / singly occupied = SOMO), fractional for degenerate open shells such as OH(2Pi), and 0 (virtual); the spatial orbitals are shared by both spins. ``n_alpha`` is the count of alpha-occupied orbitals, ``n_beta`` the beta electron count used for the ordinary integer fallback. """ energies = list(result.mo_energies) # Prefer the result's own occupations when present; fall back to the # aufbau partition from n_alpha / n_beta. occ_vec = getattr(result, "mo_occupations", None) occ_arr = np.asarray(occ_vec, dtype=float) if occ_vec is not None else None start, end = _orbital_window(energies, n_alpha, n_virtual) first_virtual = n_alpha if occ_arr is not None: occupied = np.where(occ_arr > 1.0e-8)[0] if occupied.size: first_virtual = int(occupied[-1]) + 1 def _occ(i: int) -> float: if occ_arr is not None: return float(occ_arr[i]) return 2.0 if i < n_beta else (1.0 if i < n_alpha else 0.0) def _marker(i: int) -> str: if occ_arr is not None: occ = float(occ_arr[i]) if 1.0e-8 < occ < 2.0 - 1.0e-8 and abs(occ - 1.0) > 1.0e-8: return " <-- open" if abs(occ - 1.0) <= 1.0e-8: return " <-- SOMO" if n_beta <= i < n_alpha: return " <-- SOMO" if i == n_alpha - 1 and n_alpha == n_beta: return " <-- HOMO" if i == first_virtual: return " <-- LUMO" return "" gap_line = None if 0 < first_virtual < len(energies): gap = energies[first_virtual] - energies[first_virtual - 1] gap_line = ( " (occupied)-LUMO gap: " f"{render_energy(gap, width=0, precision=6, unit='Ha')} Ha " f"({render_energy(gap, width=0, precision=4, unit='eV')} eV)" ) return _orbital_table_lines( "Molecular orbitals (Ha / eV) [restricted open-shell]", energies, start, end, _occ, _marker, gap_line, ) def _format_orbital_table_uhf( result, *, n_alpha: int, n_beta: int, n_virtual: int ) -> str: blocks: list[str] = [] for label, energies, n_occ in ( ("Alpha", list(result.mo_energies_alpha), n_alpha), ("Beta", list(result.mo_energies_beta), n_beta), ): start, end = _orbital_window(energies, n_occ, n_virtual) tag = label[0] # 'A' / 'B' def _marker(i: int, n_occ: int = n_occ, tag: str = tag) -> str: if i == n_occ - 1: return f" <-- HO{tag}MO" if i == n_occ: return f" <-- LU{tag}MO" return "" gap_line = None if 0 < n_occ < len(energies): gap = energies[n_occ] - energies[n_occ - 1] gap_line = ( f" gap ({label.lower()}): " f"{render_energy(gap, width=0, precision=6, unit='Ha')} Ha " f"({render_energy(gap, width=0, precision=4, unit='eV')} eV)" ) blocks.append( _orbital_table_lines( f"Molecular orbitals - {label}", energies, start, end, lambda i, n_occ=n_occ: 1.0 if i < n_occ else 0.0, _marker, gap_line, ) ) return "\n\n".join(blocks) _ELEMENT_SYMBOLS = ( "X", "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As", "Se", "Br", "Kr", ) def _element(z: int) -> str: if 0 < z < len(_ELEMENT_SYMBOLS): return _ELEMENT_SYMBOLS[z] return f"Z{z}" def _format_properties_block(result, molecule, basis, *, status=None) -> str: """Mulliken / Löwdin / Hirshfeld charges, Mayer bond orders, dipole moment. Emits nothing if the property computation raises (e.g. when the basis has shells vibe-qc's Python helpers can't parse); post-SCF properties are best-effort -- a missing block shouldn't crash the log writer. The Hirshfeld column is computed separately and degrades on its own: it needs a Becke-Lebedev grid build + SAD promolecule, either of which can fail independently of the (cheap, basis-space) Mulliken / Löwdin analyses. When Hirshfeld fails the table falls back to the two-column Mulliken / Löwdin layout. ``status`` Optional mutable dict. When supplied, ``status["hirshfeld"]`` is set to whether the Hirshfeld column actually computed for this call (``True`` only when the charges reached the table). This is the single observable the citation assembler gates on, so the Hirshfeld 1977 reference is emitted iff the user can see the Hirshfeld charges -- never on a grid/promolecule build failure (CLAUDE.md Sec. 8). Left untouched when the required Mulliken / Löwdin step itself fails (the whole block is absent then).""" sections: list[str] = [] try: mul = mulliken_charges(result, basis, molecule) low = loewdin_charges(result, basis, molecule) except Exception: return "" # Hirshfeld is best-effort on top of the required Mulliken/Löwdin -- # a grid-build or SAD-promolecule failure must not drop the block. try: hirsh = hirshfeld_charges(result, basis, molecule).charges except Exception: hirsh = None # Record the success exactly as it lands in the table, so the runner's # citation block can cite Hirshfeld 1977 only when it really computed. if status is not None: status["hirshfeld"] = hirsh is not None atoms = list(molecule.atoms) # Atomic charges through the Table primitive: columns + rule size to # content (retiring the hand-picked 66/52 widths), and the per-scheme # totals go in a footer -- a total row set apart by a rule, replacing the # hand-formatted "sum" line. _charge_cols = [Column("#", ">"), Column("elem", ">"), Column("Mulliken", ">"), Column("Löwdin", ">")] if hirsh is not None: _charge_cols.append(Column("Hirshfeld", ">")) _charge_tbl = Table(_charge_cols) for i, atom in enumerate(atoms, start=1): _row = [i, _element(int(atom.Z)), f"{mul[i - 1]:.6f}", f"{low[i - 1]:.6f}"] if hirsh is not None: _row.append(f"{hirsh[i - 1]:.6f}") _charge_tbl.add_row(*_row) _foot = ["", "sum", f"{float(np.sum(mul)):.6f}", f"{float(np.sum(low)):.6f}"] if hirsh is not None: _foot.append(f"{float(np.sum(hirsh)):.6f}") _charge_tbl.footer(*_foot) sections.append(" Atomic charges\n" + _charge_tbl.render()) try: B = mayer_bond_orders(result, basis, molecule) bonds = _prominent_bonds(B, molecule, threshold=0.10) except Exception: bonds = [] if bonds: _bond_tbl = Table([Column("atoms", ">"), Column("bond order", ">")]) for i, j, bo in bonds: elem_i = _element(int(atoms[i].Z)) elem_j = _element(int(atoms[j].Z)) _bond_tbl.add_row(f"{elem_i}{i + 1} -- {elem_j}{j + 1}", f"{bo:.4f}") sections.append(" Bond orders (Mayer)\n" + _bond_tbl.render()) try: dip = dipole_moment(result, basis, molecule) except Exception: dip = None if dip is not None: dx_d, dy_d, dz_d = dip.components_debye() _dip_tbl = Table([ Column("component", ">"), Column("a.u. (e.bohr)", ">"), Column("Debye", ">"), ]) _dip_tbl.add_row("x", f"{dip.x:.8f}", f"{dx_d:.4f}") _dip_tbl.add_row("y", f"{dip.y:.8f}", f"{dy_d:.4f}") _dip_tbl.add_row("z", f"{dip.z:.8f}", f"{dz_d:.4f}") _dip_tbl.add_row("|mu|", f"{dip.total:.8f}", f"{dip.total_debye:.4f}") sections.append( " Dipole moment (origin: center of mass)\n" + _dip_tbl.render() ) return "\n\n".join(sections) def format_basis_summary(basis) -> str: """Return a multi-line plain-text summary of a :class:`BasisSet`: its name, total basis-function / shell count, and a per-shell table of exponents and contraction coefficients. Used by the periodic SCF live progress logger at verbose level >= 5 so users can confirm exactly which exponents were loaded when sweeping basis-set choices on a long-running bulk run. The output is plain ASCII and indented to match the rest of :class:`vibeqc.ProgressLogger` emit. The shell-by-shell table is truncated only by the natural shell count; very large basis sets (pob-tzvp on a 50-atom cell, say) will dump several hundred lines. That's the price of asking for ``verbose=5`` on a live run -- same convention PySCF follows when its own basis log is enabled. """ name = getattr(basis, "name", "<unknown>") nbf = int(getattr(basis, "nbasis", 0)) nshells = int(getattr(basis, "nshells", 0)) lines = [ " Basis-set details", " " + "-" * 32, f" Name: {name}", f" Functions: {nbf} in {nshells} shell{'s' if nshells != 1 else ''}", "", f" {'shell':>5s} {'l':>1s} {'atom':>4s} " f"{'n_prim':>6s} exponent coefficient", " " + "-" * 60, ] L_LABEL = ["s", "p", "d", "f", "g", "h", "i"] try: shells = basis.shells() except Exception: return "\n".join(lines) for sh_idx, sh in enumerate(shells): l = int(sh.l) l_label = L_LABEL[l] if 0 <= l < len(L_LABEL) else str(l) atom_idx = int(sh.atom_index) exponents = list(sh.exponents) coeffs = list(sh.coefficients) n_prim = len(exponents) # First primitive on the header row, rest indented underneath. lines.append( f" {sh_idx:5d} {l_label:>1s} {atom_idx:4d} " f"{n_prim:6d} {exponents[0]:14.6e} {coeffs[0]:+14.6e}" ) for e, c in zip(exponents[1:], coeffs[1:]): lines.append( f" {'':5s} {'':1s} {'':4s} {'':6s} {e:14.6e} {c:+14.6e}" ) return "\n".join(lines) __all__ = [ "format_scf_trace", "write_scf_trace", "format_basis_summary", "log_scf_trace", ]