Source code for vibeview.qvf

"""QVF reader — the core file-open lifecycle.

Responsibilities:
- Open .qvf zip archive, extract + validate manifest.json
- Parse sections into pydantic models
- Verify sha256 of every binary member before use (Rule 4)
- Lazy extraction of volumetric .dat blobs
"""

from __future__ import annotations

import contextlib
import functools
import hashlib
import io
import json
import math
import re
import sys
import zipfile
import zlib
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Any, Literal, cast

import jsonschema
import numpy as np
from jsonschema.protocols import Validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

from vibeview.bonds import (
    Bond,
    BondSearchLimitError,
    DegenerateLatticeError,
    check_bond_search,
    interplanar_spacings,
    periodic_image_shells,
    structure_bonds,
)
from vibeview.elements import SYMBOL_TO_Z, z_to_symbol
from vibeview.units import BOHR_TO_ANGSTROM, EV_NM, HARTREE_TO_EV

# A bond as every reader path emits it: ``(i, j, order, image)``, where
# ``image`` is the integer lattice translation applied to ``j``. In-cell bonds
# carry ``(0, 0, 0)``; see ``vibeview.bonds`` for why the image is identity.

# ── Pydantic models for manifest.json ─────────────────────────────────────


[docs] class Source(BaseModel): program: str version: str calculation: str
[docs] class MemberSpec(BaseModel): """One member of a section (a file inside the zip).""" path: str format: str # "json" or "binary" sha256: str dtype: str | None = None shape: list[int] | None = None # The declared type of an opaque binary member, such as a ``preview`` # image (QVF spec § 4.2, § 5.15); a reader learns it here, never by # sniffing the bytes. media_type: str | None = None @field_validator("sha256") @classmethod def _validate_sha256(cls, v: str) -> str: if len(v) != 64 or not all(c in "0123456789abcdef" for c in v): raise ValueError(f"sha256 must be 64 hex chars, got: {v!r}") return v
[docs] class Section(BaseModel): """One section from the manifest.""" model_config = ConfigDict(extra="allow") id: str kind: str members: dict[str, MemberSpec] = Field(default_factory=dict) component: str | None = None # Section-level cross-reference for `reaction.waypoints`. Other # cross-refs (e.g. volume.difference.operand_a/_b) come through # model_extra. trajectory_ref: str | None = None
[docs] class ViewerDefaults(BaseModel): """Viewer defaults from manifest.json. Known fields (auto_open) are explicit. All other keys are per-section hints (e.g. ``"density": {"isovalue": 0.05, ...}``) and are captured via Pydantic's extra="allow" in ``model_extra``. """ model_config = ConfigDict(extra="allow") auto_open: list[str] = Field(default_factory=list)
[docs] class Manifest(BaseModel): model_config = ConfigDict(extra="allow") qvf_version: int source: Source sections: list[Section] viewer_defaults: ViewerDefaults | None = None
# ── Exceptions ────────────────────────────────────────────────────────────
[docs] class QVFError(Exception): """Base for all QVF reader errors."""
[docs] class QVFOpenError(QVFError): """Cannot open/read the file; validation may attach its reference report.""" def __init__( self, message: str, *, validation_report: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.validation_report = validation_report
[docs] class ValidationUnavailableError(QVFError): """The reference validator is not installed, so nothing was checked. Raised instead of a ``valid: False`` report: the archive may be perfectly valid, and only the optional ``validation`` extra is missing. The message carries the installation hint. A :class:`QVFError`, so callers that catch that keep working (#108). """
# What opening an archive or reading one of its entries can raise besides the # QVF-level checks: an unreadable path or a directory (OSError), a damaged # container or entry (BadZipFile, including "Bad CRC-32"; zlib.error; EOFError # on truncated data), an unsupported compression method (NotImplementedError) # and an encrypted entry (RuntimeError). ``QVFReader`` reports each as # ``QVFOpenError`` so every caller that handles ``QVFError`` fails cleanly # (#39-18, #39-35). _ARCHIVE_READ_ERRORS: tuple[type[BaseException], ...] = ( OSError, EOFError, zipfile.BadZipFile, zlib.error, NotImplementedError, RuntimeError, ) # What ``json.loads`` can raise on hostile input besides JSONDecodeError (a # ValueError subclass): RecursionError on deep nesting, MemoryError on a huge # document, and ValueError for e.g. an integer beyond the digit limit. _JSON_PARSE_ERRORS: tuple[type[BaseException], ...] = (RecursionError, MemoryError, ValueError) # Deepest JSON nesting a reader accepts in manifest.json or a JSON member. # Real archives stay below ten levels; the bound only keeps hostile input # away from the recursive walkers in json, jsonschema and pprint. _MAX_JSON_DEPTH = 256 def _json_depth_exceeds(value: Any, limit: int) -> bool: """Whether parsed JSON ``value`` nests more than ``limit`` containers deep. Iterative, so it cannot itself run out of recursion. """ stack: list[tuple[Any, int]] = [(value, 1)] while stack: item, depth = stack.pop() if isinstance(item, dict): children: Any = item.values() elif isinstance(item, list): children = item else: continue if depth > limit: return True stack.extend((child, depth + 1) for child in children if isinstance(child, (dict, list))) return False _DOUBLE_MAX = sys.float_info.max def _first_nonfinite_number(value: Any) -> tuple[str, str] | None: """(JSON pointer, description) of the first number no double can hold. ``json.loads`` accepts ``NaN``, ``Infinity`` and ``1e400`` (inf), none of which is JSON (RFC 8259 § 6), and integers of any size. Every consumer of manifest numbers converts with ``float()``/``int()`` or numpy, which raises OverflowError/ValueError on these (#39-35). No real archive carries one. Iterative, like :func:`_json_depth_exceeds`. """ stack: list[tuple[Any, str]] = [(value, "")] while stack: item, where = stack.pop() if isinstance(item, dict): stack.extend((child, f"{where}/{key}") for key, child in item.items()) elif isinstance(item, list): stack.extend((child, f"{where}/{i}") for i, child in enumerate(item)) elif isinstance(item, float) and not np.isfinite(item): return where or "/", repr(item) elif isinstance(item, int) and not isinstance(item, bool) and abs(item) > _DOUBLE_MAX: return where or "/", f"an integer of {len(str(abs(item)))} digits" return None # A ``\\uD800``-``\\uDFFF`` escape in JSON text. Only an escape can put a # surrogate code point into a parsed string: a strict UTF-8 decode refuses # encoded surrogates, and ``json.loads`` joins a valid escaped pair into one # character. So text without a match needs no walk. _SURROGATE_ESCAPE = re.compile(r"\\u[dD][89abcdefABCDEF]") _SURROGATE = re.compile("[\ud800-\udfff]") def _first_lone_surrogate(value: Any) -> str | None: """JSON pointer of the first string or key holding an unpaired surrogate. ``json.loads`` accepts an unpaired UTF-16 surrogate escape such as ``"\\ud800"``, but the result is not Unicode text: no UTF-8 encoding exists, so printing, writing or serving the string raises UnicodeEncodeError wherever it goes (#39-35). RFC 8259 § 8.2 leaves the behaviour undefined, and I-JSON (RFC 7493 § 2.1) forbids it. A key is named by its parent's pointer, since the key itself cannot be shown. Iterative, like :func:`_json_depth_exceeds`. """ stack: list[tuple[Any, str]] = [(value, "")] while stack: item, where = stack.pop() if isinstance(item, dict): for key, child in item.items(): if _SURROGATE.search(key): return f"{where or '/'} (an object key)" stack.append((child, f"{where}/{key}")) elif isinstance(item, list): stack.extend((child, f"{where}/{i}") for i, child in enumerate(item)) elif isinstance(item, str) and _SURROGATE.search(item): return where or "/" return None def _lone_surrogate_in(text: str, value: Any) -> str | None: """:func:`_first_lone_surrogate` of ``value``, parsed from ``text``.""" return _first_lone_surrogate(value) if _SURROGATE_ESCAPE.search(text) else None #: Largest ``atomic_number`` the reader accepts: the int64 maximum. #: Numbers beyond the element table are kept on purpose (a converter labels #: ``Atom(999)`` "Z999", and frame metadata keeps unknown display labels); #: the bond search stores atomic numbers as int64. MAX_ATOMIC_NUMBER = 2**63 - 1 def _atomic_number(value: Any, field: str) -> int: """An atomic number from 0 (the schema's minimum) to the int64 maximum. The schema bounds ``atomic_number`` below only. 2**63 or more used to open, and then made the bond search raise OverflowError converting it to int64 in ``h-add`` (#39-35). """ number = _json_integer(value, field) if not 0 <= number <= MAX_ATOMIC_NUMBER: shown = str(number) if abs(number) < 10**30 else f"a {number.bit_length()}-bit integer" raise QVFError( f"{field} is {shown}, outside 0..{MAX_ATOMIC_NUMBER} (a 64-bit signed integer)" ) return number def _validation_message(error: Exception) -> str: """``str(error)`` for a schema failure, or a short form if that fails. jsonschema's message pretty-prints the offending instance, which recurses without bound on a deeply nested manifest value (#39-35). """ try: return str(error) except RecursionError: path = "/".join(str(p) for p in getattr(error, "absolute_path", ())) return ( f"manifest.json failed schema validation at /{path}" " (the offending value is nested too deeply to show)" ) def _describe_exception(error: BaseException, limit: int = 300) -> str: """``"Type: message"`` for an unexpected error, bounded and never raising.""" try: text = str(error) except Exception: # e.g. RecursionError rendering a deep value text = "" if len(text) > limit: text = text[:limit] + "..." return f"{type(error).__name__}: {text}" if text else type(error).__name__
[docs] class ManifestValidationError(QVFError): """manifest.json failed JSON Schema validation."""
[docs] class SHA256MismatchError(QVFError): """sha256 of a member does not match the manifest. Hard error for that section."""
@dataclass(frozen=True) class _SectionError: message: str kind: str
[docs] class SectionNotFoundError(QVFError): """Requested section id not found in manifest."""
[docs] class CriticalSectionUnsupportedError(QVFError): """A section (or root extension) flagged ``critical: true`` has a kind the viewer cannot render. Per the QVF spec (§2.2 / §5.5 / §7) the consumer MUST refuse to open such a file rather than silently render a partial, misleading view."""
# Minimum run lengths. One residue cannot be a helix: 3.6 residues is a # single turn, and a "strand" of two is just two adjacent atoms. Shorter # runs than these are noise from the per-residue test and become coil. _MIN_HELIX_RUN = 4 _MIN_STRAND_RUN = 3 def _virtual_angle(p0: np.ndarray, p1: np.ndarray, p2: np.ndarray) -> float: """CA(i-1)-CA(i)-CA(i+1) angle, degrees.""" a, b = p0 - p1, p2 - p1 na, nb = np.linalg.norm(a), np.linalg.norm(b) if na == 0.0 or nb == 0.0: return float("nan") return float(np.degrees(np.arccos(np.clip(np.dot(a, b) / (na * nb), -1.0, 1.0)))) def _virtual_dihedral(p0: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> float: """CA(i)-CA(i+1)-CA(i+2)-CA(i+3) dihedral, degrees, signed. The sign convention here is fixed by measurement, not by assertion: an ideal right-handed alpha-helix gives +50 and its left-handed mirror -50 (pinned in ``tests/test_secondary_structure.py``). Getting this backwards would make every helix read as a left-handed one. """ b0, b1, b2 = p0 - p1, p2 - p1, p3 - p2 n1 = np.linalg.norm(b1) if n1 == 0.0: return float("nan") b1 = b1 / n1 v = b0 - np.dot(b0, b1) * b1 w = b2 - np.dot(b2, b1) * b1 x, y = np.dot(v, w), np.dot(np.cross(b1, v), w) if x == 0.0 and y == 0.0: return float("nan") return float(np.degrees(np.arctan2(y, x))) def _collapse_short_runs(labels: list[str]) -> list[str]: """Demote runs shorter than their minimum to coil.""" out = list(labels) i = 0 while i < len(out): j = i while j < len(out) and out[j] == out[i]: j += 1 need = {"H": _MIN_HELIX_RUN, "E": _MIN_STRAND_RUN}.get(out[i]) if need is not None and (j - i) < need: for k in range(i, j): out[k] = "C" i = j return out def _assign_secondary_structure(trace: np.ndarray) -> list[str]: """Per-CA "H"/"E"/"C" for one chain's CA trace. See :meth:`StructureData.secondary_structure` for the provenance of the descriptors and of the windows below. """ n = len(trace) if n == 0: return [] labels = ["C"] * n def dist(i: int, k: int) -> float: if i + k >= n: return float("nan") return float(np.linalg.norm(trace[i + k] - trace[i])) for i in range(n): d2, d3, d4 = dist(i, 2), dist(i, 3), dist(i, 4) tau = ( _virtual_angle(trace[i - 1], trace[i], trace[i + 1]) if 0 < i < n - 1 else float("nan") ) alpha = ( _virtual_dihedral(trace[i], trace[i + 1], trace[i + 2], trace[i + 3]) if i + 3 < n else float("nan") ) # Helix: the i/i+3 and i/i+4 contacts are the signature — a helix # brings residues one turn apart back into van der Waals contact, # which nothing else does. Ideal: d3 5.05, d4 6.20, tau 90.4, # alpha +50.0. if ( not np.isnan(d3) and 4.4 <= d3 <= 6.0 # d4 runs off the end for the last four residues of a chain. # Requiring it there would clip the final turn of a helix that # ends at the C-terminus. and (np.isnan(d4) or 4.8 <= d4 <= 7.0) and (np.isnan(tau) or 78.0 <= tau <= 105.0) and (np.isnan(alpha) or 25.0 <= alpha <= 80.0) ): labels[i] = "H" continue # Strand: extended, so every separation grows nearly linearly and # the dihedral sits near 180. Ideal: d2 6.68, d3 10.20, d4 13.36, # tau 120.7, alpha 180.0. if ( not np.isnan(d2) and not np.isnan(d3) and d2 >= 6.2 and d3 >= 9.5 and (np.isnan(d4) or d4 >= 12.0) and (np.isnan(tau) or tau >= 108.0) and (np.isnan(alpha) or abs(alpha) >= 135.0) ): labels[i] = "E" # A detection at i describes the whole window starting at i, so the # residues that window covers inherit the label; without this the last # turn of every helix reads as coil. # # This reads `core` and writes `spread`. Bleeding in place instead # makes the loop read labels it has just written, so a single core # detection chain-reacts forward three residues at a time until it # meets a non-coil label. Measured on DHFR, that inflated a sane # 12% H / 14% E / 74% C core into 57% H / 33% E / 10% C, with a # 24-residue "strand" at the N-terminus. core = list(labels) spread = list(labels) for i, label in enumerate(core): reach = {"H": 4, "E": 4}.get(label) if reach is None: continue for k in range(i + 1, min(i + reach, n)): if spread[k] == "C": spread[k] = label return _collapse_short_runs(spread)
[docs] @dataclass class Atom: """One atom. The biomolecular fields are optional and default to None. Chemistry files (XYZ, a computed structure) carry none of them; a PDB carries all five, and the cartoon/ribbon work (roadmap workstream D) is built on them — ``atom_name`` identifies the backbone trace, ``chain_id`` + ``residue_seq`` order it, ``residue_name`` colours it, ``b_factor`` carries the temperature factor (or, for a predicted structure, the per-residue confidence a predictor writes into that column). Optional rather than required so every existing archive, and every non-biomolecular producer, keeps working untouched. """ symbol: str position: np.ndarray # [3] float64, angstroms atomic_number: int atom_name: str | None = None # PDB cols 13-16, e.g. "CA" (unpadded) residue_name: str | None = None # cols 18-20, e.g. "ALA" residue_seq: int | None = None # cols 23-26 chain_id: str | None = None # col 22 b_factor: float | None = None # cols 61-66
def _opt_str(value: Any) -> str | None: """A trimmed string, or None when absent/blank.""" if value is None: return None text = str(value).strip() return text or None #: Section-id prefix of wavefunction overlays computed by re-localization #: (``vibeview.relocalize``); a geometry edit invalidates every one of them. RELOCALIZED_PREFIX = "wf_relocalized_" def _atom_from_json(entry: Mapping[str, Any], position: np.ndarray | None = None) -> Atom: """One :class:`Atom` from a validated JSON atom entry. ``position`` overrides the entry's (frame and scan metadata carry identities, not coordinates). The optional biomolecular fields are read when present, so a vibrations or scan entry that carries none gets None. """ if position is None: position = np.array(entry["position"], dtype=np.float64) # The schema pins a structure atom's position; a vibrations entry is # not schema-checked, and a two-number position broke ``animate`` # outside the reader (#39-35). if position.shape != (3,) or not np.all(np.isfinite(position)): raise QVFError( f"atom position must be three finite numbers, got shape {position.shape}" ) return Atom( symbol=entry["symbol"], position=position, atomic_number=_atomic_number(entry["atomic_number"], "atomic_number"), atom_name=_opt_str(entry.get("atom_name")), residue_name=_opt_str(entry.get("residue_name")), residue_seq=_opt_int(entry.get("residue_seq")), chain_id=_opt_str(entry.get("chain_id")), b_factor=_opt_float(entry.get("b_factor")), ) def _str_or_none(value: Any) -> str | None: """``str(value)``, or None when the value is absent (no trimming).""" return None if value is None else str(value) def _opt_int(value: Any) -> int | None: """An int, or None when absent/unparseable (never raises on bad input: a malformed residue number must not sink the whole structure).""" if value is None or isinstance(value, bool): return None try: return int(value) except (TypeError, ValueError): return None def _opt_float(value: Any) -> float | None: """A float, or None when absent/unparseable (never raises: one bad b-factor must not sink the whole structure).""" if value is None or isinstance(value, bool): return None try: return float(value) except (TypeError, ValueError): return None # QVF `secondary_structure[].type` (spec § 5.1) to this module's per-CA # descriptor. Anything else falls back to coil rather than raising: the # schema pins the enum, but a hand-built archive is not obliged to pass it. _SS_TYPE_TO_LABEL = {"helix": "H", "sheet": "E", "coil": "C"} def _resolve_pbc(raw: dict[str, Any]) -> tuple[tuple[bool, bool, bool], int]: """Resolve per-axis periodicity from a ``structure`` payload. ``pbc`` is normative when present: it is per-axis, so it is strictly more expressive than the scalar ``dimensionality``. When only ``dimensionality`` is given, the first ``dim`` axes are the periodic ones — the convention the the QVF format uses for its lattice matrix (rows are the cell vectors). ``dimensionality`` is therefore *derived*, never read alongside ``pbc``: the QVF invariant is ``dimensionality == sum(pbc)`` (spec § 5.1). An archive that carries both and disagrees is self-contradictory, and nothing here can tell which field is the lie. Guessing one is exactly how a 2D slab came to be drawn inside a phantom vacuum box, so we refuse the archive instead. Note ``pbc`` need not be a contiguous prefix — ``[True, False, True]`` is a legal slab periodic in x and z, and the spec deliberately does not require the periodic axes to be the leading ones. ``dim`` then only counts the periodic axes; it does not say *which*. Consumers that need the axes must read ``pbc``, never reconstruct them from ``dim``. """ raw_pbc = raw.get("pbc") raw_dim = raw.get("dimensionality") dim = int(raw_dim) if raw_dim is not None else None if raw_pbc is not None: flags = [bool(x) for x in raw_pbc][:3] flags += [False] * (3 - len(flags)) n_periodic = sum(flags) if dim is not None and dim != n_periodic: raise QVFError( f"structure payload is self-contradictory: pbc={flags} has " f"{n_periodic} periodic axes but dimensionality={dim}; the QVF " "invariant is dimensionality == sum(pbc) (spec § 5.1)" ) return (flags[0], flags[1], flags[2]), n_periodic if dim is not None: flags = [i < dim for i in range(3)] return (flags[0], flags[1], flags[2]), dim return (False, False, False), 0
[docs] def clamp_replication( replication: tuple[int, int, int], pbc: tuple[bool, bool, bool] ) -> tuple[int, int, int]: """Force the replication count to 1 on every non-periodic axis. Replicating along a non-periodic axis would tile the structure along a synthesized, non-physical lattice column — for a 2D slab that fabricates a stack of sheets that the calculation never contained. """ return tuple( # type: ignore[return-value] max(1, int(n)) if p else 1 for n, p in zip(replication, pbc, strict=True) )
#: Sentinel default for :attr:`StructureData.dim`: derive it from ``pbc``. _DIM_FROM_PBC = -1
[docs] @dataclass class StructureData: """A unit cell, or a molecule when ``pbc`` is all-False. For ``dim < 3`` the lattice rows ``dim..2`` are **non-physical**: the producer synthesizes them so AO integrals and spglib always see a full-rank 3x3 matrix, and the SCF energy is provably invariant to their length. They are NOT cell edges and must never be drawn, replicated along, or used as a geometric extent. ``pbc`` is the only signal that separates a synthesized column from a real lattice vector — a synthesized 30-bohr normal and a real 30-bohr vacuum gap are numerically identical in ``lattice_vectors``. """ atoms: list[Atom] pbc: tuple[bool, bool, bool] lattice_vectors: np.ndarray | None # [3,3] or None for molecules # explicit (i, j, order, image) where image is the integer lattice # translation applied to j; (0,0,0) for an ordinary in-cell bond. bonds: list[Bond] | None # Periodic-axis count, always ``sum(pbc)`` (QVF spec § 5.1: the derived # ``dimensionality`` invariant). Omit it and it is derived; pass it and it # must agree with ``pbc`` (#35-16). It carries no axis identity: which # axes are periodic is ``pbc``'s alone. dim: int = _DIM_FROM_PBC # Producer-supplied biomolecule metadata from the `structure` section # object (QVF spec § 5.1). None when the archive carries none of it, # which is the case for every plain chemistry structure. Where these # ARE supplied they take precedence over this class's own derivation: # a producer knows what CA geometry cannot reveal (an alpha from a pi # from a 3-10 helix, a beta bridge from a sheet). supplied_residues: list[dict[str, Any]] | None = None supplied_chains: list[str] | None = None supplied_secondary_structure: list[dict[str, Any]] | None = None # Biomolecular identity derived once per instance: the cartoon and the # colour schemes ask for the chain map per chain and per frame, and on a # 20k-atom protein each walk costs milliseconds. The cache assumes what # every caller does, that residue identities are not edited in place # after a read; positions may change freely (they are not part of it). _has_residues: bool | None = field(default=None, init=False, repr=False, compare=False) _chain_map: dict[str, list[tuple[int, list[int]]]] | None = field( default=None, init=False, repr=False, compare=False ) _ca_all: list[tuple[str, int, int]] | None = field( default=None, init=False, repr=False, compare=False ) def __post_init__(self) -> None: n_periodic = sum(bool(flag) for flag in self.pbc) if self.dim == _DIM_FROM_PBC: self.dim = n_periodic elif self.dim != n_periodic: raise ValueError( f"dim={self.dim} contradicts pbc={tuple(self.pbc)}: dim must equal " f"the number of periodic axes ({n_periodic})" ) @property def has_residues(self) -> bool: """True when this structure carries biomolecular identity. Either carrier counts: per-atom residue numbers, or a producer-supplied ``residues`` list on the section object. A producer may legitimately supply the second without the first. """ if self._has_residues is None: self._has_residues = bool(self.supplied_residues) or any( a.residue_seq is not None for a in self.atoms ) return self._has_residues
[docs] def chain_ids(self) -> list[str]: """Chain ids, in presentation order. The producer-supplied ``chains`` list when the archive has one (it may order chains for presentation, and may name a chain that contributes no residue), otherwise the chains :meth:`chains` derived. """ if self.supplied_chains: return [str(c) for c in self.supplied_chains] return list(self.chains().keys())
def _supplied_chain_map(self) -> dict[str, list[tuple[int, list[int]]]] | None: """:meth:`chains` shape built from ``supplied_residues``, or None. Returns None when nothing usable was supplied, so the caller falls back to derivation rather than rendering an empty ribbon: an out-of-range ``atom_indices`` entry is a producer bug, and blanking the cartoon over it would be the wrong failure. """ if not self.supplied_residues: return None n = len(self.atoms) out: dict[str, list[tuple[int, list[int]]]] = {} for residue in self.supplied_residues: if not isinstance(residue, dict): continue seq = _opt_int(residue.get("seq")) if seq is None: continue indices = [ i for i in (_opt_int(v) for v in residue.get("atom_indices") or ()) if i is not None and 0 <= i < n ] if not indices: continue out.setdefault(str(residue.get("chain", "")), []).append((seq, indices)) return out or None
[docs] def chains(self) -> dict[str, list[tuple[int, list[int]]]]: """``{chain_id: [(residue_seq, [atom indices]), ...]}``. A producer-supplied ``residues`` list wins over the derivation below (spec § 5.1 precedence). Everything after this paragraph describes that derivation, which is what runs for a PDB import and for every archive whose producer supplied nothing. Residues appear in the order the file lists them, which for a PDB is the order along the chain — the ribbon must follow the actual connectivity, and sorting by residue number would silently reorder insertion codes or non-monotonic numbering. Atoms with no residue identity are skipped rather than lumped into a fake chain; a file with none simply yields ``{}``. Grouping is by **contiguous run** of (chain, sequence, name), not by a global key. PDB residue numbers are only four columns wide and wrap at 9999, so a solvated system reuses them: keying globally merged a lipid and a water that happened to share number 1001 into one 80-atom "residue" (measured: 1,256 such collisions on a 150k-atom membrane protein, 9,999 buckets where the file holds 12,010 residues). Contiguity is also what makes insertion codes behave, since those too are listed in order. """ if self._chain_map is None: supplied = self._supplied_chain_map() self._chain_map = supplied if supplied is not None else self._derive_chains() return self._chain_map
def _derive_chains(self) -> dict[str, list[tuple[int, list[int]]]]: """The :meth:`chains` derivation from per-atom residue identity.""" out: dict[str, list[tuple[int, list[int]]]] = {} bucket: list[int] | None = None prev_key: tuple[str, int, str | None] | None = None for i, atom in enumerate(self.atoms): if atom.residue_seq is None: bucket, prev_key = None, None # a gap ends the current run continue chain = atom.chain_id or "" key = (chain, atom.residue_seq, atom.residue_name) if key != prev_key: bucket = [] out.setdefault(chain, []).append((atom.residue_seq, bucket)) prev_key = key assert bucket is not None bucket.append(i) return out
[docs] def ca_residues( self, chain_id: str | None = None ) -> list[tuple[str, int, int]]: """``(chain, residue_seq, CA atom index)`` for each CA-bearing residue. The single ordering used by :meth:`backbone_trace` and by the supplied-assignment branch of :meth:`secondary_structure`, so the two cannot drift apart: a per-CA label list that did not line up with the trace it is zipped against would mislabel a whole chain. **Public because renderers need it.** Anything building a per-CA array to zip against ``backbone_trace()`` — a selection mask, a colour array — must walk this exact ordering rather than re-deriving it, since a second copy of the walk can drift. It was private until 2026-07-26, which forced the cartoon renderer to reach in through ``getattr`` and carry a duplicated fallback. """ if self._ca_all is None: picked: list[tuple[str, int, int]] = [] for chain, residues in self.chains().items(): for seq, indices in residues: for i in indices: if self.atoms[i].atom_name == "CA": picked.append((chain, seq, i)) break self._ca_all = picked if chain_id is None: return list(self._ca_all) return [entry for entry in self._ca_all if entry[0] == chain_id]
[docs] def backbone_trace(self, chain_id: str | None = None) -> np.ndarray: """Alpha-carbon coordinates, in chain order — the ribbon spine. ``[N, 3]``; empty when the structure has no CA atoms. Pass ``chain_id`` for a single chain, or leave it None to concatenate every chain (callers drawing one ribbon per chain should ask per chain, so the spline does not jump between them). """ picked = [self.atoms[i].position for _c, _s, i in self.ca_residues(chain_id)] if not picked: return np.zeros((0, 3), dtype=np.float64) return np.asarray(picked, dtype=np.float64)
def _supplied_labels(self, chain: str, *, detailed: bool = False) -> list[str] | None: """Per-CA labels for ``chain`` from the supplied ranges, or None. None when nothing was supplied for this chain, so the caller falls back to its own geometric assignment. Precedence is decided **per chain**: a producer that annotates chain A and says nothing about chain B means B is unannotated, not that B is entirely coil. Residues the supplied ranges do not cover are coil, which is what a range list omitting the loops between its helices means. """ if not self.supplied_secondary_structure: return None ranges: list[tuple[int, int, str]] = [] for entry in self.supplied_secondary_structure: if not isinstance(entry, dict) or str(entry.get("chain", "")) != chain: continue start = _opt_int(entry.get("start_seq")) end = _opt_int(entry.get("end_seq")) if start is None or end is None: continue label = _SS_TYPE_TO_LABEL.get(str(entry.get("type", "")), "C") if detailed: # Optional viewer extension within the schema's open range # object. Keep the normative helix/sheet/coil type unchanged. subtype = str(entry.get("subtype", "")) if label == "H": label = {"alpha": "H", "pi": "I", "3_10": "G"}.get(subtype, "H") elif label == "E" and subtype == "bridge": label = "B" ranges.append((min(start, end), max(start, end), label)) if not ranges: return None out: list[str] = [] for _chain, seq, _i in self.ca_residues(chain): label = "C" for start, end, ranged_label in ranges: if start <= seq <= end: label = ranged_label break out.append(label) return out
[docs] def secondary_structure( self, chain_id: str | None = None, *, detailed: bool = False ) -> list[str]: """Per-alpha-carbon secondary structure, aligned with :meth:`backbone_trace`. One character per CA: ``"H"`` helix, ``"E"`` extended strand, ``"C"`` coil. The list is always the same length as the trace for the same ``chain_id``, so a renderer can zip the two. With ``detailed=True``, supplied subtype annotations additionally return ``G`` (3-10 helix), ``I`` (pi helix) and ``B`` (beta bridge). Geometric assignment always returns the coarse H/E/C labels. A producer-supplied ``secondary_structure`` range list wins, per chain, over the geometric assignment described below (spec § 5.1 precedence). That is not a preference for tidiness: the CA-only assignment below genuinely cannot tell an alpha- from a pi- from a 3-10 helix, or a beta-bridge from a sheet, and a producer can. Assignment is from the CA trace alone. Real DSSP (Kabsch & Sander, Biopolymers 22, 2577 (1983)) reads backbone N/CA/C/O and assigns from hydrogen-bond energies; a QVF is not guaranteed to carry O at all, and vibe-view needs this only to decide ribbon geometry. CA-only assignment is an established family of approximations to it, the reference implementation being P-SEA (Labesse, Colloc'h, Pothier & Mornon, Comput. Appl. Biosci. 13, 291 (1997)), which discriminates on CA(i)-CA(i+k) distances plus the virtual CA angle and dihedral. This uses those same descriptors. The numeric windows below are **this implementation's**, chosen to bracket ideal geometry rather than transcribed from the paper. ``tests/test_secondary_structure.py`` constructs an ideal right-handed alpha-helix (1.5 A rise, 100 deg/residue, CA on a 2.3 A radius) and an ideal extended strand (3.34 A rise) and measures, under this module's dihedral sign convention: helix d2 5.43 d3 5.05 d4 6.20 tau 90.4 alpha 50.0 strand d2 6.68 d3 10.20 d4 13.36 tau 120.7 alpha 180.0 A left-handed helix measures alpha -50.0, so the dihedral sign is what keeps one from being read as the other. Assignment is per chain even when ``chain_id`` is None: running the window across a chain boundary would invent structure out of two unrelated termini that happen to be adjacent in the file. """ chains = self.chains() out: list[str] = [] for chain in chains: if chain_id is not None and chain != chain_id: continue supplied = self._supplied_labels(chain, detailed=detailed) if supplied is not None: out.extend(supplied) continue out.extend(_assign_secondary_structure(self.backbone_trace(chain))) return out
[docs] @dataclass class GridData: """Geometry of a scalar field on a regular grid. A stored QVF volume keeps ``origin`` and ``voxel_vectors`` in **bohr** (design § 1.3a), the default. Fields the viewer samples itself, such as an orbital evaluated from the GTO wavefunction, are laid out in Å alongside the atoms and say so with ``units="angstrom"``. Anything that places a grid in the Å scene scales by :attr:`to_angstrom` instead of assuming one convention. """ origin: np.ndarray # [3] voxel_vectors: np.ndarray # [3,3], row i is the step along grid axis i shape: tuple[int, int, int] units: Literal["bohr", "angstrom"] = "bohr" def __post_init__(self) -> None: if self.units not in ("bohr", "angstrom"): raise ValueError(f"grid units must be 'bohr' or 'angstrom', not {self.units!r}") @property def to_angstrom(self) -> float: """Factor that takes this grid's coordinates into Å.""" return 1.0 if self.units == "angstrom" else BOHR_TO_ANGSTROM
[docs] @dataclass class VectorFieldData: """A ``volume.vector`` section: a Cartesian vector at every grid point. ``vectors[i, j, k]`` is the vector at ``origin + (i, j, k) @ voxel_vectors`` in the Cartesian frame of ``origin`` (QVF spec § 5.2, #156). ``unit`` is the declared ``grid.units.data``, or ``None`` when the producer declares none. """ grid: GridData vectors: np.ndarray # [nx, ny, nz, 3] unit: str | None = None
[docs] @dataclass class ComplexFieldData: """A ``volume.complex`` section: a complex scalar at every grid point (#157). ``values[i, j, k]`` is ``re + i·im`` at ``origin + (i, j, k) @ voxel_vectors`` (QVF spec § 5.2). ``unit`` is the declared ``grid.units.data``, or ``None``. """ grid: GridData values: np.ndarray # [nx, ny, nz] complex128 unit: str | None = None
[docs] @dataclass class ForcesData: """A ``forces`` section (QVF spec § 5.10, #159). ``gradient`` is the stored energy gradient ∂E/∂R in hartree/bohr, one row per atom of ``structure_id``; the force is its negative, :attr:`forces`. ``stress`` is in hartree/bohr³. """ gradient: np.ndarray # [n_atoms, 3] structure_id: str stress: np.ndarray | None = None # [3, 3] method: str | None = None @property def forces(self) -> np.ndarray: """F = −∇E, which the spec says a drawing MUST use (§ 5.10).""" return -np.asarray(self.gradient, dtype=float)
[docs] @dataclass class HessianData: """A ``hessian`` section (QVF spec § 5.11, #159). ``matrix[3a + c, 3b + d]`` is ∂²E/∂x∂y in hartree/bohr², or hartree/(bohr²·amu) when ``mass_weighted``; atoms are ``structure_id``'s. """ matrix: np.ndarray # [3N, 3N] structure_id: str mass_weighted: bool = False projected: bool = False atom_masses_amu: list[float] | None = None
[docs] @dataclass class BlochWavefunctionData: """A ``wavefunction.bloch`` section: orbitals at k-points (§ 5.3.1, #162). ``coefficients[k, s, m, μ]`` is the complex coefficient of Bloch function μ in orbital m of spin channel s at k-point k; ``kpoints`` are fractional reciprocal-lattice coordinates. ``n_mo_per_k[k]`` orbitals are defined at k-point k; the rest are padding. """ structure_id: str pure: bool n_ao: int shells: list[BasisShell] kpoints: np.ndarray # [n_k, 3] spin: str # "restricted" | "unrestricted" coefficients: np.ndarray # [n_k, n_spin, n_mo, n_ao] complex128 energies: np.ndarray # [n_k, n_spin, n_mo] hartree occupations: np.ndarray # [n_k, n_spin, n_mo] n_mo_per_k: list[int] weights: list[float] | None = None labels: list[str] | None = None mesh: list[int] | None = None time_reversal: bool = False orbital_kind: str = "canonical" fermi_energy_eh: float | None = None @property def n_spin(self) -> int: return int(self.coefficients.shape[1])
[docs] @dataclass class PropertiesData: """A ``properties.electric``, ``.elastic`` or ``.dielectric`` section (§ 5.13, #161). ``payload`` is the ``properties`` member as stored, its shapes checked; ``structure_id`` is the structure whose axes it uses, or ``None`` when the archive does not resolve one. """ kind: str payload: dict[str, Any] structure_id: str | None = None
[docs] @dataclass class SeriesAxis: """What indexes the slabs of a ``volume.series`` (QVF spec § 5.2).""" kind: str # "time", "frame", "state" or "custom" values: list[float] # one per slab, in ``units`` units: str | None = None labels: list[str] | None = None trajectory_ref: str | None = None
[docs] @dataclass class VolumeSeriesData: """A ``volume.series`` section: ``n`` scalar fields on one grid (#158).""" grid: GridData data: np.ndarray # [n, nx, ny, nz] axis: SeriesAxis
[docs] @dataclass class BandsData: kpath: dict[str, Any] # raw kpath.json eigenvalues: np.ndarray # [n_spin, n_kpoints, n_bands] fermi: float | None
[docs] @dataclass class PhononBandsData: qpath: dict[str, Any] # raw qpath.json (segments, n_modes, …) frequencies: np.ndarray # [n_qpoints, n_modes], cm^-1
[docs] @dataclass class PhononDOSData: frequencies: np.ndarray # [n_points], cm^-1 dos: np.ndarray # [n_points], states / cm^-1 meta: dict[str, Any]
[docs] @dataclass class EquationOfStateData: volumes: np.ndarray # [n_points], Angstrom^3 energies: np.ndarray # [n_points], eV fit: dict[str, Any] # model, V0, E0, B0, B0_prime, units, …
[docs] @dataclass class SpectraData: frequencies: np.ndarray # [n_modes], native axis unit (eV, cm⁻¹, ...) intensities: np.ndarray # [n_modes], in the spectrum kind's unit (km/mol for IR) labels: list[str] | None = None # optional per-transition assignments
[docs] @dataclass class TrajectoryData: atoms: list[Atom] # atom types (fixed across frames) coords: np.ndarray # [n_frames, n_atoms, 3] energies: list[float] | None # per-frame energies
[docs] @dataclass class VibrationsData: atoms: list[Atom] frequencies: np.ndarray # [n_modes] cm⁻¹ displacements: np.ndarray # [n_modes, n_atoms, 3] # Optional per-mode metadata (QVF VibrationsMetadataPayload), each # [n_modes] in the schema's unit (#150). ir_intensities: np.ndarray | None = None # km·mol⁻¹ raman_activities: np.ndarray | None = None # Å⁴·amu⁻¹ reduced_masses: np.ndarray | None = None # amu force_constants: np.ndarray | None = None # mdyn·Å⁻¹ symmetry_labels: list[str] | None = None
[docs] @dataclass class AtomPropertiesData: mulliken_charges: np.ndarray | None # [n_atoms] float64 loewdin_charges: np.ndarray | None # [n_atoms] float64 spin_populations: np.ndarray | None = None # [n_atoms] float64 hirshfeld_charges: np.ndarray | None = None # [n_atoms] float64 # Knizia IAO partial charges. Unlike the schemes above these are # basis-set stable, so they are the ones to trust when comparing # charges computed in different basis sets. iao_charges: np.ndarray | None = None # [n_atoms] float64
[docs] @dataclass class CitationsData: """BibTeX bibliography embedded in the QVF. The writer stores `references.bib` as a binary member (utf-8 bytes + sha256). We decode here so renderers see a string. """ bibtex: str
def _safe_basename(name: Any, fallback: str) -> str: """A producer-supplied filename reduced to a safe bare basename. The files index travels inside the archive, so its ``filename`` is untrusted input on a path that ends in a browser "save as". Strip any directory component (including Windows separators) and reject the traversal names outright, so a crafted archive cannot propose ``../../.bashrc`` as a download name. """ text = str(name or "").strip().replace("\\", "/") base = text.rsplit("/", 1)[-1] if not base or base in (".", ".."): return fallback return base
[docs] @dataclass class RunRecordData: """run.record section payload — the self-contained record of one program invocation (verbatim input + full log, spec § 5.8). ``input_text`` / ``log_text`` are decoded UTF-8 (None when the member is absent); ``input_size`` / ``log_size`` are the uncompressed byte counts from the ZIP central directory so a renderer can size-gate what it pushes to the DOM. ``files`` is the optional filename index; ``attachment_roles`` lists attachment members present (their bytes are not read here). """ program: str program_version: str | None command: str | None exit_status: int | None started_utc: str | None finished_utc: str | None sequence: int | None input_text: str | None input_size: int log_text: str | None log_size: int files: dict[str, Any] attachment_roles: list[str]
[docs] @dataclass class JobSpecData: """job.spec section payload — the declarative specification of the calculation the archive *requests* (spec § 5.9). ``raw`` keeps the full JobSpecPayload (open for forward growth); the typed fields mirror its portable top level.""" job_type: str method: str | None basis: str | None functional: str | None charge: int | None multiplicity: int | None kpoints: list[Any] | None tasks: list[Any] options: dict[str, Any] raw: dict[str, Any]
#: The identity fields a ``structure`` section object may carry (QVF spec #: § 5.1, "Chemical identity"), besides the generic ``label``. STRUCTURE_IDENTITY_FIELDS: tuple[str, ...] = ( "name", "charge", "multiplicity", "smiles", "inchi", "inchikey", )
[docs] @dataclass(frozen=True) class StructureIdentity: """Which chemical species a ``structure`` section holds (QVF spec § 5.1). Read from the section object, never from a member. Every field is optional, and an absent one states nothing: a structure without ``charge`` is not declared neutral. ``label`` is the generic section label, the short compound label a scheme shows ("1", "3a"); ``name`` is the chemical name. A value of the wrong type reads as absent. """ label: str | None = None name: str | None = None charge: int | None = None multiplicity: int | None = None smiles: str | None = None inchi: str | None = None inchikey: str | None = None @property def title(self) -> str: """``"<label> · <name>"``, either alone, or ``""`` when neither is given.""" return " · ".join(part for part in (self.label, self.name) if part)
[docs] def as_dict(self) -> dict[str, Any]: """The fields that are present, for JSON output.""" return { key: value for key in ("label", *STRUCTURE_IDENTITY_FIELDS) if (value := getattr(self, key)) is not None }
#: The arrows of a reaction step (QVF spec § 5.14); absent means forward. REACTION_ARROWS: tuple[str, ...] = ("forward", "equilibrium", "retrosynthetic", "resonance")
[docs] @dataclass(frozen=True) class ReactionParticipant: """One species of a reaction step: a ``structure`` section and its coefficient.""" structure_ref: str coefficient: float = 1.0
[docs] @dataclass(frozen=True) class ReactionStep: """One step of a ``reaction.scheme`` (QVF spec § 5.14). Roles are chemical: ``reactants`` are consumed and ``products`` formed in the step's forward direction whatever the ``arrow``. """ id: str reactants: list[ReactionParticipant] products: list[ReactionParticipant] reagents: list[ReactionParticipant] = field(default_factory=list) label: str | None = None arrow: str = "forward" conditions: list[str] = field(default_factory=list) yield_percent: float | None = None yield_text: str | None = None
[docs] @dataclass(frozen=True) class ReactionSchemeData: """A ``reaction.scheme`` payload: its steps in reading order.""" steps: list[ReactionStep]
#: Raster types a ``preview`` thumbnail may have (QVF spec § 5.15). PREVIEW_RASTER_TYPES: frozenset[str] = frozenset({"image/png", "image/jpeg"})
[docs] @dataclass(frozen=True) class PreviewImage: """One verified ``preview`` member: its declared type and its bytes.""" role: str media_type: str data: bytes
[docs] @dataclass class NMRData: """NMR section payload. Writer (qvf.py::_write_spectra_nmr_section) pass-through of a dict with conventional keys: chemical_shifts, shielding_tensors, j_couplings, isotope, reference, solvent. Shape of each field is not schema-enforced, so we keep the raw dict and let the renderer decide what to surface. """ raw: dict[str, Any]
[docs] @dataclass class EPRData: """EPR section payload. Writer (qvf.py::_write_spectra_epr_section) pass-through of a dict with conventional keys: g_tensor, hyperfine, zero_field_splitting. Shape of each field is not schema-enforced, so we keep the raw dict and let the renderer decide what to surface. """ raw: dict[str, Any]
[docs] @dataclass class SymmetryData: """spglib-style symmetry analysis embedded in the QVF. Writer (qvf.py::_write_symmetry_section) passes through whatever dict the producer hands it (space group number, symbol, point group, Hall symbol, …). Keys are conventional, not enforced by schema. The renderer surfaces every key it finds. """ raw: dict[str, Any]
[docs] @dataclass class SCFHistoryData: """Per-iteration SCF trail. Writer (qvf.py::_write_scf_history_section) stores a JSON document `{"iterations": [{"iter", "energy_eh", "delta_e", "diis_error"}, ...]}`. Field set is conventional, not enforced by schema — keep them as plain dicts so the renderer can degrade gracefully when a key is missing (e.g. non-DIIS solvers have no `diis_error`). """ iterations: list[dict[str, float | int]]
[docs] @dataclass class BondOrdersData: """Bond-order analysis section.""" method: str # "mayer", "wiberg", etc. pairs: list[dict[str, object]] # each has i, j, order, distance_ang, ...
[docs] @dataclass class QTAIMData: """QTAIM topological analysis section.""" points: list[dict[str, object]] # type, position, rho, laplacian, ... bond_paths: list[dict[str, object]] | None # atoms, path
[docs] @dataclass class DOSData: """A ``dos.total`` or ``dos.projected`` section (spec § 4.8 / § 4.9). ``values`` is the DOS ``[n_pts]`` or ``[2, n_pts]`` (spin axis leading) for a total DOS, and the per-channel projections ``[n_ch, n_pts]`` for a projected one, with a spin axis already summed away. ``channels`` is the section's channel list, empty for a total DOS. """ energies: np.ndarray # [n_pts], eV values: np.ndarray channels: list[object] projected: bool
[docs] @dataclass class FermiSurfaceData: """A ``fermi_surface`` section: the energy mesh and its metadata.""" energies: np.ndarray # [nk1, nk2, nk3, n_bands], E - E_F mesh: dict[str, object] # lattice_vectors, labels, ...
[docs] @dataclass class DOSCOOPData: """COOP/COHP bonding analysis section (dos.coop or dos.cohp).""" energies: np.ndarray # [n_pts] projections: np.ndarray # [n_pairs, n_pts] integrated: np.ndarray # [n_pairs] meta: dict[str, object] # pair_labels, fermi_energy_ev, ...
[docs] @dataclass class BasisShell: """One contracted shell of the GTO basis (§ 1.5).""" center: int # 0-based atom index l: int # noqa: E741 — `l` is the QVF field name for angular momentum exponents: np.ndarray # [n_prim] bohr^-2 coefficients: np.ndarray # [n_prim] contraction coefficients (normalized primitives) pure: bool = True # spherical (True) or Cartesian (False)
[docs] @dataclass class TransitionOrbitals: """The excited state a set of natural transition orbitals describes (spec § 5.3). Read from the canonical carriers first: ``state_index`` and the ``excitation_energy`` of that state in the ``excited_states`` section ``state_ref`` names. Archives written before those existed carry the deprecated ``excitation_index`` and ``excitation_energy_ev`` instead, which are read when the canonical carrier is absent, so both show the same state. Each field is None when the archive does not say. """ #: ``"hole"`` or ``"particle"``, from ``nto_role``. role: str | None = None #: The state's number: ``state_index``, else ``excitation_index``. state_index: int | None = None #: The state's excitation energy in eV. excitation_energy_ev: float | None = None #: The ``excited_states`` section that ``state_ref`` names. states_ref: str | None = None
[docs] @dataclass(frozen=True) class ExcitedStateContribution: """One dominant orbital excitation of a state (QVF spec § 5.12). ``from_mo`` and ``to_mo`` are 0-based rows of the coefficient member of the reference wavefunction; ``spin`` names the channel of an unrestricted reference. """ from_mo: int to_mo: int weight: float | None = None spin: str | None = None
[docs] @dataclass(frozen=True) class ExcitedState: """One excited state of an ``excited_states`` section (QVF spec § 5.12). Energies are converted from the units each value declares: the excitation energy to eV, the total energy to hartree. Every other field is None when the payload does not state it. ``nto_ref`` and ``density_ref`` are section ids, not loaded data. """ index: int excitation_energy_ev: float total_energy_eh: float | None = None multiplicity: int | None = None symmetry: str | None = None oscillator_strength: float | None = None rotatory_strength_cgs: float | None = None transition_dipole: tuple[float, float, float] | None = None transition_magnetic_dipole: tuple[float, float, float] | None = None contributions: tuple[ExcitedStateContribution, ...] = () nto_ref: str | None = None density_ref: str | None = None @property def wavelength_nm(self) -> float | None: """The transition wavelength, or None for a non-positive energy.""" if self.excitation_energy_ev <= 0: return None return EV_NM / self.excitation_energy_ev
[docs] @dataclass class ExcitedStatesData: """An ``excited_states`` payload with the section's references (spec § 5.12).""" states: list[ExcitedState] #: The ``meta`` object as written: ``method``, ``reference_state``, ... meta: dict[str, Any] #: The ``wavefunction.gto`` section of the reference state, if named. wavefunction_ref: str | None = None #: The structure whose axes the transition dipoles use, if named. structure_ref: str | None = None
#: Energy units an excited-state payload may declare (spec § 4.3), as a #: factor to eV. Anything else is a malformed payload, never a guess. _ENERGY_UNIT_TO_EV: dict[str, float] = {"ev": 1.0, "hartree": HARTREE_TO_EV} def _quantity_in_ev(raw: Any, field: str) -> float: quantity = _json_object(raw) value = quantity.get("value") if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value): raise QVFError(f"{field}.value must be a finite number") factor = _ENERGY_UNIT_TO_EV.get(str(quantity.get("units", "")).lower()) if factor is None: raise QVFError(f"{field}.units {quantity.get('units')!r} is not an energy unit") return float(value) * factor def _optional_number(state: dict[str, Any], field: str) -> float | None: value = state.get(field) if value is None: return None if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value): raise QVFError(f"{field} must be a finite number") return float(value) def _optional_vector(state: dict[str, Any], field: str) -> tuple[float, float, float] | None: value = state.get(field) if value is None: return None if not isinstance(value, list) or len(value) != 3: raise QVFError(f"{field} must have three components") components = [] for component in value: if ( isinstance(component, bool) or not isinstance(component, int | float) or not math.isfinite(component) ): raise QVFError(f"{field} must have three finite components") components.append(float(component)) return components[0], components[1], components[2] def _optional_text(state: dict[str, Any], field: str) -> str | None: value = state.get(field) if value is None: return None if not isinstance(value, str): raise QVFError(f"{field} must be a string") return value def _excited_state(raw: Any) -> ExcitedState: state = _json_object(raw) index = _json_integer(state.get("index"), "index") if index < 1: raise QVFError("index must be at least 1") multiplicity = state.get("multiplicity") if multiplicity is not None: multiplicity = _json_integer(multiplicity, "multiplicity") if multiplicity < 1: raise QVFError("multiplicity must be at least 1") contributions_raw = state.get("contributions", []) if not isinstance(contributions_raw, list): raise QVFError("contributions must be a list") contributions = [] for item in contributions_raw: contribution = _json_object(item) spin = contribution.get("spin") if spin is not None and spin not in ("alpha", "beta"): raise QVFError("contribution spin must be 'alpha' or 'beta'") contributions.append( ExcitedStateContribution( from_mo=_json_integer(contribution.get("from_mo"), "from_mo"), to_mo=_json_integer(contribution.get("to_mo"), "to_mo"), weight=_optional_number(contribution, "weight"), spin=spin, ) ) total = state.get("total_energy") return ExcitedState( index=index, excitation_energy_ev=_quantity_in_ev(state.get("excitation_energy"), "excitation_energy"), total_energy_eh=( None if total is None else _quantity_in_ev(total, "total_energy") / HARTREE_TO_EV ), multiplicity=multiplicity, symmetry=_optional_text(state, "symmetry"), oscillator_strength=_optional_number(state, "oscillator_strength"), rotatory_strength_cgs=_optional_number(state, "rotatory_strength_cgs"), transition_dipole=_optional_vector(state, "transition_dipole"), transition_magnetic_dipole=_optional_vector(state, "transition_magnetic_dipole"), contributions=tuple(contributions), nto_ref=_optional_text(state, "nto_ref"), density_ref=_optional_text(state, "density_ref"), ) LOCALIZED_ORBITAL_KINDS = frozenset({"localized", "ibo", "iao", "boys", "pipek_mezey", "nbo"})
[docs] @dataclass class WavefunctionGTOData: """`wavefunction.gto` payload. For restricted: `mo_coefficients` is set, `mo_coefficients_alpha` and `mo_coefficients_beta` are None. For unrestricted: the per-spin variants are set, and `mo_coefficients` is None. Each coefficient matrix is row-major [n_mo, n_ao]. """ structure_ref: str pure: bool n_ao: int shells: list[BasisShell] spin: str # "restricted" | "unrestricted" orbital_kind: str # "canonical" | "natural" | "localized" energies: np.ndarray | None # [n_mo] (restricted only) occupations: np.ndarray | None # [n_mo] (restricted only) symmetry_labels: list[str] | None alpha_energies: np.ndarray | None alpha_occupations: np.ndarray | None beta_energies: np.ndarray | None beta_occupations: np.ndarray | None mo_coefficients: np.ndarray | None # [n_mo, n_ao] float64 mo_coefficients_alpha: np.ndarray | None mo_coefficients_beta: np.ndarray | None # Unrestricted β symmetry labels. α (and restricted) labels live in # `symmetry_labels`; keeping β separate stops β orbitals from inheriting # α's labels (audit L7). Optional with a default so existing readers that # predate the split still construct. symmetry_labels_beta: list[str] | None = None # Localization payload, present only when `orbital_kind == "localized"`. # A localized orbital has no orbital energy, so these carry what actually # identifies it: which atoms it sits on, and where it sits. # atom_populations [n_mo, n_atoms] — n_A(i); each row sums to 1 # centroids_bohr [n_mo, 3] — <i|r|i> # n_centres [n_mo] — atoms above the centre threshold atom_populations: np.ndarray | None = None centroids_bohr: np.ndarray | None = None n_centres: np.ndarray | None = None localization_method: str | None = None # Meaning of the numeric entries stored under ``occupations``. Natural # orbitals and natural *transition* orbitals both use # ``orbital_kind="natural"``, but the former carry electron occupations # while the latter carry transition weights. Consumers must not infer # that distinction from a section id or from a coincidental sum. occupation_semantics: str | None = None # Explicit Bloch wavevector metadata; None retains legacy cluster semantics. k_point: np.ndarray | None = None # Session-only provenance/descriptors returned by the external worker. relocalization: dict[str, Any] | None = None # Some imports lack the density matrix or orbital convention needed to # reconstruct an electron density, while individual MOs remain valid. density_unavailable_reason: str | None = None # Set for natural transition orbitals: the state they describe. transition_orbitals: TransitionOrbitals | None = None
[docs] @dataclass class ReactionWaypoint: """One reaction-path waypoint annotation.""" frame_index: int label: str kind: str # "reactant" | "transition_state" | "intermediate" | "product" | "point" energy_eh: float | None = None
[docs] @dataclass class ReactionPathData: """Self-contained reaction path (`reaction.path`). Binary layout matches `trajectory`: coords[n_frames, n_atoms, 3] in Å. Periodic reaction paths additionally carry the per-frame lattice + dimensionality so the renderer can draw the cell and wrap atoms across periodic boundaries. A path is periodic when it has the ``lattice`` member, never because of the manifest's ``qvf_version`` (QVF spec § 3.1): * ``lattice`` is None for molecular paths. Otherwise a float64 array of shape (3, 3) when every frame shares the same lattice (the fixed-cell common case) or (n_frames, 3, 3) for variable- cell paths. Columns are a, b, c, in **bohr** — matching ``vibeqc.PeriodicSystem.lattice``. The renderer is responsible for the bohr→Å conversion (coords are already Å). * ``dim`` is None for molecular paths. Otherwise an int in {1, 2, 3}; ``dim_per_frame`` is set instead when frames carry different dimensionalities. """ atoms: list[Atom] coords: np.ndarray # [n_frames, n_atoms, 3] float64 Å energies: list[float] | None reaction_coordinate: list[float] | None waypoints: list[ReactionWaypoint] lattice: np.ndarray | None = None # bohr; None or (3,3) or (n_frames,3,3) dim: int | None = None # 1/2/3; shared across frames dim_per_frame: list[int] | None = None # variable-dim case # Optional human-readable annotations for the energy-plot x-axis. reaction_coordinate_label: str | None = None # e.g. "O–H distance" reaction_coordinate_unit: str | None = None # e.g. "bohr", "rad" # Optional per-frame volumetric data (W1). frame_volumes is a 4D # array [n_emitted, nx, ny, nz]; volume_frame_index maps each # emitted slab to a path frame (decimation); volume_grid is the # shared real-space grid (bohr). None on archives without volumes. frame_volumes: np.ndarray | None = None volume_grid: GridData | None = None volume_frame_index: list[int] | None = None volume_label: str | None = None volume_isovalue: float | None = None
[docs] @dataclass class ReactionWaypointsData: """Waypoint annotations layered on a referenced trajectory.""" trajectory_ref: str waypoints: list[ReactionWaypoint] reaction_coordinate: list[float] | None
[docs] @dataclass class ScanSurfaceData: """2D relaxed-scan energy surface (`scan.surface`). ``energies`` is ``[nA, nB]`` (Hartree); ``axis_a`` / ``axis_b`` are the 1D driven-coordinate values. Optional ``geometries`` is the relaxed structure at each node, flattened ``[nA*nB, n_atoms, 3]`` in Å (row-major over ``(a, b)``); ``atoms`` carries the element symbols. """ axis_a: np.ndarray # (nA,) axis_b: np.ndarray # (nB,) energies: np.ndarray # (nA, nB) coordinate_a_label: str | None = None coordinate_a_unit: str | None = None coordinate_b_label: str | None = None coordinate_b_unit: str | None = None atoms: list[Atom] | None = None geometries: np.ndarray | None = None # (nA*nB, n_atoms, 3) Å
def _json_object(raw: Any) -> dict[str, Any]: if not isinstance(raw, dict): raise QVFError("payload is not a JSON object") return cast(dict[str, Any], raw) def _json_integer(value: Any, field: str) -> int: # Match JSON Schema integer semantics, including 1.0 but excluding bool. if ( isinstance(value, bool) or not isinstance(value, int | float) or (isinstance(value, float) and (not np.isfinite(value) or not value.is_integer())) ): raise QVFError(f"{field} must be an integer") return int(value) def _json_finite_number(value: Any, field: str) -> float: if isinstance(value, bool) or not isinstance(value, int | float): raise QVFError(f"{field} must be a finite number") try: number = float(value) except OverflowError: raise QVFError(f"{field} must be a finite number") from None if not np.isfinite(number): raise QVFError(f"{field} must be a finite number") return number def _grid_from_json(raw: Any, data_shape: Sequence[int]) -> GridData: payload = _json_object(raw) def vector(value: Any, field: str) -> list[float]: if not isinstance(value, list) or len(value) != 3: raise QVFError(f"{field} must contain exactly three numbers") return [_json_finite_number(v, f"{field}[{i}]") for i, v in enumerate(value)] origin = vector(payload.get("origin"), "origin") vectors = payload.get("voxel_vectors") if not isinstance(vectors, list) or len(vectors) != 3: raise QVFError("voxel_vectors must be a 3x3 matrix") voxel_vectors = [vector(v, f"voxel_vectors[{i}]") for i, v in enumerate(vectors)] shape_raw = payload.get("shape") if not isinstance(shape_raw, list) or len(shape_raw) != 3: raise QVFError("shape must contain exactly three dimensions") shape = tuple(_json_integer(v, f"shape[{i}]") for i, v in enumerate(shape_raw)) if any(n < 0 for n in shape): raise QVFError("shape dimensions must be nonnegative") if shape != tuple(data_shape): raise QVFError(f"grid shape {shape} does not match binary shape {tuple(data_shape)}") return GridData( origin=np.asarray(origin, dtype=np.float64), voxel_vectors=np.asarray(voxel_vectors, dtype=np.float64), shape=cast(tuple[int, int, int], shape), ) def _numeric_array(value: Any, shape: tuple[int, ...], name: str) -> np.ndarray: """``value`` as a finite float array of exactly ``shape``, else :class:`QVFError`.""" try: array = np.asarray(value, dtype=float) except (TypeError, ValueError): raise QVFError(f"{name} must be numbers of shape {list(shape)}") from None if array.shape != shape or not np.all(np.isfinite(array)): raise QVFError(f"{name} must be finite numbers of shape {list(shape)}") return array def _check_property_shapes(kind: str, payload: dict[str, Any], n_atoms: int | None) -> None: """The shapes of a ``properties.*`` payload that the viewer reads (§ 5.13, #161).""" def field(owner: dict[str, Any], key: str, shape: tuple[int, ...], name: str) -> None: if owner.get(key) is not None: _numeric_array(owner[key], shape, name) if kind == "properties.electric": for key, inner, shape in ( ("dipole", "vector", (3,)), ("quadrupole", "matrix", (3, 3)), ("octupole", "tensor", (27,)), ("polarizability", "matrix", (3, 3)), ("hyperpolarizability", "tensor", (27,)), ): block = payload.get(key) if block is None: continue if not isinstance(block, dict) or block.get(inner) is None: raise QVFError(f"{key} needs its {inner}") field(block, inner, shape, f"{key}.{inner}") origin = payload.get("origin") if isinstance(origin, list): _numeric_array(origin, (3,), "origin") elif kind == "properties.elastic": if payload.get("stiffness") is None: raise QVFError("stiffness is required") field(payload, "stiffness", (6, 6), "stiffness") field(payload, "compliance", (6, 6), "compliance") elif kind == "properties.dielectric": if payload.get("electronic") is None: raise QVFError("electronic is required") for key in ("electronic", "ionic", "static"): field(payload, key, (3, 3), key) field(payload, "piezoelectric", (3, 6), "piezoelectric") charges = payload.get("born_charges") if charges is not None: count = len(charges) if isinstance(charges, list) else -1 if n_atoms is not None and count != n_atoms: raise QVFError(f"born_charges has {count} tensors for {n_atoms} atoms") _numeric_array(charges, (count, 3, 3), "born_charges") _SERIES_AXIS_KINDS = ("time", "frame", "state", "custom") def _series_axis_from_json(raw: dict[str, Any], n_slabs: int) -> SeriesAxis: """The ``axis`` payload of a ``volume.series``, one value per slab (#158).""" kind = raw.get("kind") if kind not in _SERIES_AXIS_KINDS: raise QVFError(f"axis kind must be one of {', '.join(_SERIES_AXIS_KINDS)}, not {kind!r}") values_raw = raw.get("values") if not isinstance(values_raw, list): raise QVFError("axis values must be a list") values = [_json_finite_number(v, f"values[{i}]") for i, v in enumerate(values_raw)] if len(values) != n_slabs: raise QVFError(f"axis has {len(values)} values for {n_slabs} slabs") labels = raw.get("labels") if labels is not None: if not isinstance(labels, list) or not all(isinstance(x, str) for x in labels): raise QVFError("axis labels must be a list of strings") if len(labels) != n_slabs: raise QVFError(f"axis has {len(labels)} labels for {n_slabs} slabs") units = raw.get("units") reference = raw.get("trajectory_ref") return SeriesAxis( kind=str(kind), values=values, units=units if isinstance(units, str) else None, labels=list(labels) if labels is not None else None, trajectory_ref=reference if isinstance(reference, str) else None, ) def _frame_atoms_from_lists(symbols: Any, numbers: Any) -> list[Atom]: """Atom identities from ``symbols`` and/or ``atomic_numbers`` (spec § 5.5). Either list alone names the atoms; when both are present they must have one entry per atom each and agree wherever the symbol is an element. A missing symbol is taken from the atomic number (``Z<n>`` beyond the table), a missing atomic number from the symbol (0 for a symbol that is no element). """ for name, value in (("symbols", symbols), ("atomic_numbers", numbers)): if value is not None and not isinstance(value, list): raise QVFError(f"{name} must be a list") if symbols is not None and numbers is not None and len(symbols) != len(numbers): raise QVFError( f"symbols has {len(symbols)} entries and atomic_numbers {len(numbers)}; " "they must name the same atoms" ) count = len(symbols) if symbols is not None else len(numbers or []) atoms = [] for i in range(count): symbol: str | None = None number: int | None = None if symbols is not None: symbol = symbols[i] if not isinstance(symbol, str) or not symbol: raise QVFError(f"symbols[{i}] must be a nonempty string") if numbers is not None: number = _atomic_number(numbers[i], f"atomic_numbers[{i}]") if symbol is None: assert number is not None symbol = z_to_symbol(number, default=f"Z{number}") elif number is None: number = SYMBOL_TO_Z.get(symbol, 0) elif SYMBOL_TO_Z.get(symbol, number) != number: raise QVFError( f"symbols[{i}] {symbol!r} and atomic_numbers[{i}] {number} " "name different atoms" ) atoms.append(Atom(symbol=symbol, position=np.zeros(3), atomic_number=number)) return atoms def _frame_atoms_from_json(meta: dict[str, Any]) -> list[Atom]: """Atom identities of ``trajectory`` and ``reaction.path`` metadata. The metadata names its atoms through at least one of ``atoms``, ``symbols`` and ``atomic_numbers`` (spec § 5.5). ``atoms`` is read when present; otherwise the two lists are. """ raw = meta.get("atoms") if raw is None and ("symbols" in meta or "atomic_numbers" in meta): return _frame_atoms_from_lists(meta.get("symbols"), meta.get("atomic_numbers")) if not isinstance(raw, list): raise QVFError("atoms must be a list") atoms = [] for i, entry in enumerate(raw): if not isinstance(entry, dict): raise QVFError(f"atoms[{i}] must be a JSON object") symbol = entry.get("symbol") if not isinstance(symbol, str) or not symbol: raise QVFError(f"atoms[{i}].symbol must be a nonempty string") number = _atomic_number(entry.get("atomic_number"), f"atoms[{i}].atomic_number") # Frame metadata carries identities, not positions. Keep legacy display # labels (including unknown elements) as read_structure does. atoms.append(Atom(symbol=symbol, position=np.zeros(3), atomic_number=number)) return atoms
[docs] def scf_energy_eh(provenance: Mapping[str, Any]) -> float | None: """``provenance["scf_energy"]`` as a float in hartree, or None. Producers write the energy either as a bare number or as ``{"value": E, "unit": "hartree"}``; every surface that prints or compares energies reads it through here. """ energy = provenance.get("scf_energy") if isinstance(energy, Mapping): energy = energy.get("value") if isinstance(energy, int | float) and not isinstance(energy, bool): return float(energy) return None
def _frame_energies(meta: dict[str, Any]) -> list[float] | None: values = meta.get("energies") if values is None: return None if not isinstance(values, list): raise QVFError("energies must be a list or null") # Empty/short prefixes remain supported: not every archived frame has an # energy, and get_energy explicitly returns None beyond the available list. return [_json_finite_number(v, f"energies[{i}]") for i, v in enumerate(values)] def _waypoints_from_json(payload: dict[str, Any], n_frames: int) -> list[ReactionWaypoint]: raw = payload.get("waypoints", []) if not isinstance(raw, list): raise QVFError("waypoints must be a list") result = [] for i, entry in enumerate(raw): if not isinstance(entry, dict): raise QVFError(f"waypoints[{i}] must be a JSON object") index = _json_integer(entry.get("frame_index"), f"waypoints[{i}].frame_index") if not 0 <= index < n_frames: raise QVFError( f"waypoints[{i}].frame_index {index} is out of range for {n_frames} frames" ) label, kind = entry.get("label"), entry.get("kind") if not isinstance(label, str) or not isinstance(kind, str): raise QVFError(f"waypoints[{i}].label and kind must be strings") energy = ( _json_finite_number(entry["energy_eh"], f"waypoints[{i}].energy_eh") if "energy_eh" in entry else None ) result.append(ReactionWaypoint(index, label, kind, energy)) return result def _has_immutable_bytes(array: np.ndarray) -> bool: """Read-only flags alone do not prove a view's backing storage immutable.""" base: object = array while isinstance(base, np.ndarray): if base.flags.writeable: return False base = base.base return isinstance(base, bytes) def _parse_ij_pairs(raw: Any, n_atoms: int | None = None) -> list[dict[str, Any]]: """Validate pair indices and present orders without losing analysis metadata.""" if not isinstance(raw, dict): raise QVFError("payload is not a JSON object") pairs = raw.get("pairs", []) if not isinstance(pairs, list): raise QVFError("field 'pairs' is not a list") result: list[dict[str, Any]] = [] for index, pair in enumerate(pairs): if not isinstance(pair, dict): raise QVFError(f"pair {index} is not a JSON object") normalized = dict(pair) for key in ("i", "j"): number = _json_integer(pair.get(key), f"pair {index} field {key!r}") if number < 0 or (n_atoms is not None and number >= n_atoms): bound = f" for {n_atoms} atoms" if n_atoms is not None else "" raise QVFError(f"pair {index} field {key!r} index {number} is out of range{bound}") normalized[key] = number if "order" in pair: normalized["order"] = _json_finite_number( pair["order"], f"pair {index} field 'order'", ) result.append(normalized) return result # ── Manifest loader ─────────────────────────────────────────────────────── _SCHEMA_PATH = Path(__file__).parent / "schema.json" # The manifest schema of the pinned qvf release, vendored byte for byte and # loaded once at module level. with open(_SCHEMA_PATH) as f: _MANIFEST_SCHEMA: dict[str, Any] = json.load(f) #: The ``$id`` of the vendored v1 schema, the ``schema_uri`` of a v1 manifest. MANIFEST_SCHEMA_URI: str = _MANIFEST_SCHEMA["$id"] # One schema per version a manifest is *read as*. qvf_version 2 is not here: # it was withdrawn, and an archive stamped with it (vibe-qc 0.10-0.15, a # periodic reaction.path) is read and checked as version 1 against this same # schema, with the warning W_VERSION_WITHDRAWN (QVF spec § 3.1). There is no # v2 schema to carry. _SCHEMAS_BY_VERSION: dict[int, dict[str, Any]] = { 1: _MANIFEST_SCHEMA, } # The withdrawn version and the version it is read as (QVF spec § 3.1). _WITHDRAWN_VERSION = 2 _WITHDRAWN_READ_AS = 1 def _structure_payload_validator(schema: dict[str, Any]) -> Validator: # Legacy viewer symbols are display labels: imports preserve Z999, and # notebook exports safely escape arbitrary text. Retain those contracts # while requiring a nonempty string. Never mutate the pinned schema or # the definitions used to validate manifests. definitions = deepcopy(schema["$defs"]) definitions["StructureAtom"]["properties"]["symbol"].pop("maxLength") return jsonschema.Draft202012Validator({ "$ref": "#/$defs/StructurePayload", "$defs": definitions, }) _STRUCTURE_VALIDATORS = { version: _structure_payload_validator(schema) for version, schema in _SCHEMAS_BY_VERSION.items() } _MANIFEST_VALIDATORS: dict[int, Validator] = {} def _validate_manifest(instance: Any, qvf_version: int, schema: dict[str, Any]) -> None: """``jsonschema.validate`` with the schema checked once, not per open. ``jsonschema.validate`` re-validates the schema against its meta-schema on every call, which took ~0.15 s of every archive open. Same errors: the best match of ``iter_errors``, raised as ``ValidationError``. """ validator = _MANIFEST_VALIDATORS.get(qvf_version) if validator is None: cls = jsonschema.validators.validator_for(schema) cls.check_schema(schema) validator = _MANIFEST_VALIDATORS[qvf_version] = cls(schema) error = jsonschema.exceptions.best_match(validator.iter_errors(instance)) if error is not None: raise error def _load_manifest_schema(qvf_version: int = 1) -> dict[str, Any]: """Return the vendored JSON Schema for manifest.json at ``qvf_version``.""" schema = _SCHEMAS_BY_VERSION.get(qvf_version) if schema is None: raise ManifestValidationError( f"unsupported qvf_version {qvf_version!r}; vibe-view reads version 1 " f"and the withdrawn version {_WITHDRAWN_VERSION} (QVF spec § 3.1)" ) return schema def _schema_known_kinds(schema: dict[str, Any]) -> set[str]: """Collect the section kinds the schema defines a branch for. Each ``$defs/Section*`` constrains ``kind`` to a ``const`` (the vendor branch uses a pattern instead). We harvest those consts so the reader can tell a *schema-unknown* kind (forward-compat / reserved-but-unwritten, e.g. ``fermi_surface``) apart from a genuinely malformed section, and open the archive instead of rejecting it wholesale (audit A3-01). """ known: set[str] = set() for sub in (schema.get("$defs") or {}).values(): if not isinstance(sub, dict): continue kind = (sub.get("properties") or {}).get("kind") if isinstance(kind, dict) and isinstance(kind.get("const"), str): known.add(kind["const"]) return known # Kinds the consumer can actually act on (render directly, or — for # ``bonds`` — fold into the structure view). Used to decide whether a # ``critical: true`` section must block the open (§ 5.5 of the spec). def _consumer_usable_kinds() -> frozenset[str]: from vibeview.kinds import RENDERED_VIA_STRUCTURE, SUPPORTED_KINDS from vibeview.renderer_plugins import plugin_kinds # A kind an installed renderer plugin claims is one this consumer can act # on, so a critical section of it opens (#113). return SUPPORTED_KINDS | RENDERED_VIA_STRUCTURE | plugin_kinds() def _mo_vector(raw: Any, name: str) -> np.ndarray: """One value per orbital (or mode). A bare number parsed as a 0-d array, which the MO table and picker then failed to ``len()`` outside the reader (#39-35).""" arr = np.asarray(raw, dtype=np.float64) if arr.ndim != 1: raise QVFError(f"{name} must be a flat list of numbers") return arr _VIBRATION_PER_MODE_NUMBERS = ( "ir_intensities", "raman_activities", "reduced_masses", "force_constants", ) def _vibration_per_mode(meta: dict[str, Any], n_modes: int) -> dict[str, Any]: """The optional per-mode vibration fields, one value per mode (#150). Like ``frequencies``, a field that is present must be a flat list with one entry per mode; a wrong length is refused rather than misaligned. """ fields: dict[str, Any] = {} for name in _VIBRATION_PER_MODE_NUMBERS: if meta.get(name) is not None: fields[name] = _mo_vector(meta[name], name) labels = meta.get("symmetry_labels") if labels is not None: if not isinstance(labels, list) or not all(isinstance(x, str) for x in labels): raise QVFError("symmetry_labels must be a list of strings") fields["symmetry_labels"] = list(labels) for name, values in fields.items(): if len(values) != n_modes: raise QVFError(f"{name} has {len(values)} entries for {n_modes} modes") return fields def _spectra_from_json(raw: dict[str, Any]) -> SpectraData: """``frequencies`` and ``intensities``: flat, finite, one shared length. Lists of different lengths passed the reader and made every spectrum plot fail while broadening, as a ``ValueError`` from ``zip`` (#66). """ frequencies = _mo_vector(raw["frequencies"], "frequencies") intensities = _mo_vector(raw["intensities"], "intensities") if len(frequencies) != len(intensities): raise QVFError( f"frequencies [{len(frequencies)}] and intensities [{len(intensities)}] " "must have one shared length" ) if not np.isfinite(frequencies).all(): raise QVFError("frequencies must be finite") if not np.isfinite(intensities).all(): raise QVFError("intensities must be finite") labels = raw.get("labels") if labels is not None and ( not isinstance(labels, list) or len(labels) != len(frequencies) or not all(isinstance(label, str) for label in labels) ): raise QVFError("spectrum labels must contain one string per transition") return SpectraData(frequencies=frequencies, intensities=intensities, labels=labels) def _shell_n_ao(l: int, pure: bool) -> int: # noqa: E741 — angular momentum """Atomic orbitals of one shell: ``2l+1`` spherical, ``(l+1)(l+2)/2`` Cartesian.""" return 2 * l + 1 if pure else (l + 1) * (l + 2) // 2 def _shell_from_json(raw: Any, index: int, global_pure: bool) -> BasisShell: """One basis shell; a malformed one is a :class:`QVFError` naming it. A shell whose exponent and coefficient counts differed passed the reader and made the first orbital evaluation raise ``ValueError`` (#66). """ sh = _json_object(raw) center = _json_integer(sh["center"], f"shells[{index}].center") l = _json_integer(sh["l"], f"shells[{index}].l") # noqa: E741 — angular momentum label = f"shell {index} (l={l}, center={center})" if center < 0: raise QVFError(f"{label}: center must be a non-negative atom index") if l < 0: raise QVFError(f"{label}: l must be non-negative") exponents = np.asarray(sh["exponents"], dtype=np.float64) coefficients = np.asarray(sh["coefficients"], dtype=np.float64) if exponents.ndim != 1 or coefficients.ndim != 1: raise QVFError(f"{label}: exponents and coefficients must be flat lists of numbers") if len(exponents) != len(coefficients): raise QVFError( f"{label}: {len(exponents)} exponents but " f"{len(coefficients)} contraction coefficients" ) if len(exponents) == 0: raise QVFError(f"{label}: needs at least one primitive") if not np.isfinite(exponents).all() or not (exponents > 0).all(): raise QVFError(f"{label}: exponents must be positive finite numbers") if not np.isfinite(coefficients).all(): raise QVFError(f"{label}: contraction coefficients must be finite") return BasisShell( center=center, l=l, exponents=exponents, coefficients=coefficients, pure=bool(sh.get("pure", global_pure)), ) def _transition_orbitals(meta: Mapping[str, Any], orbital_kind: str) -> TransitionOrbitals | None: """The state of a natural-transition-orbital set from its ``mo_metadata``. None unless the orbitals are NTOs: ``orbital_kind`` ``"nto"``, or ``"natural"`` with any NTO key (``nto_role``, ``state_index`` or the deprecated ``excitation_index`` / ``excitation_energy_ev``). These keys only label the set, so a malformed value is left out, not an error. The excitation energy here is the deprecated key's; the reader replaces it with the canonical one from the ``excited_states`` section. """ keys = ("nto_role", "state_index", "excitation_index", "excitation_energy_ev") if orbital_kind != "nto" and not ( orbital_kind == "natural" and any(key in meta for key in keys) ): return None def integer(key: str, minimum: int) -> int | None: value = meta.get(key) if isinstance(value, bool) or not isinstance(value, int) or value < minimum: return None return value role = meta.get("nto_role") energy = meta.get("excitation_energy_ev") reference = meta.get("state_ref") state_index = integer("state_index", 1) return TransitionOrbitals( role=role if role in ("hole", "particle") else None, state_index=state_index if state_index is not None else integer("excitation_index", 0), excitation_energy_ev=( float(energy) if isinstance(energy, int | float) and not isinstance(energy, bool) and math.isfinite(energy) else None ), states_ref=reference if isinstance(reference, str) and reference else None, ) def _wavefunction_from_json( basis_raw: dict[str, Any], meta_raw: dict[str, Any], binaries: dict[str, np.ndarray], ) -> WavefunctionGTOData: """Build :class:`WavefunctionGTOData` from parsed members; raises on malformed input.""" global_pure = bool(basis_raw.get("pure", True)) shells = [ _shell_from_json(sh, index, global_pure) for index, sh in enumerate(basis_raw.get("shells", [])) ] n_ao = int(basis_raw.get("n_ao", 0)) # The basis's own reference, or "" when it names none; the reader resolves # it against the section's ``structure_ref`` and the first structure. structure_ref = str(basis_raw.get("structure_ref") or "") spin = str(meta_raw.get("spin", "restricted")) orbital_kind = str(meta_raw.get("orbital_kind", "canonical")) occupation_semantics = _opt_str(meta_raw.get("occupation_semantics")) transition_orbitals = _transition_orbitals(meta_raw, orbital_kind) if transition_orbitals is not None: # Natural transition orbitals take vibe-view's natural-orbital path # with transition weights, whether the archive says # ``orbital_kind: "nto"`` (spec § 5.3) or, as vibe-qc wrote them # before, ``"natural"`` with the deprecated excitation keys. orbital_kind = "natural" occupation_semantics = occupation_semantics or "transition_weight" energies: np.ndarray | None = None occupations: np.ndarray | None = None sym_labels: list[str] | None = None sym_labels_beta: list[str] | None = None a_e = a_o = b_e = b_o = None coeffs = a_c = b_c = None if spin == "unrestricted": alpha = meta_raw.get("alpha", {}) or {} beta = meta_raw.get("beta", {}) or {} a_e = _mo_vector(alpha.get("energies", []), "alpha.energies") a_o = _mo_vector(alpha.get("occupations", []), "alpha.occupations") b_e = _mo_vector(beta.get("energies", []), "beta.energies") b_o = _mo_vector(beta.get("occupations", []), "beta.occupations") # Per-spin labels (audit L7): the α list must not stand in for β. sym_labels = alpha.get("symmetry_labels") sym_labels_beta = beta.get("symmetry_labels") a_c = binaries.get("mo_coefficients_alpha") b_c = binaries.get("mo_coefficients_beta") else: energies = _mo_vector(meta_raw.get("energies", []), "energies") occupations = _mo_vector(meta_raw.get("occupations", []), "occupations") sym_labels = meta_raw.get("symmetry_labels") coeffs = binaries.get("mo_coefficients") def _decode_coefficients(values: np.ndarray | None) -> np.ndarray | None: if values is None: return None encoding = meta_raw.get("coefficient_encoding") if encoding == "complex_split_last_axis": if values.ndim != 3 or values.shape[-1] != 2: raise QVFError("Complex MO coefficients must have shape [n_mo, n_ao, 2]") components = meta_raw.get("coefficient_components", ["real", "imag"]) if components != ["real", "imag"]: raise QVFError("Complex MO components must be ordered real, imag") values = values[..., 0] + 1j * values[..., 1] elif encoding not in (None, "real"): raise QVFError(f"Unsupported MO coefficient encoding: {encoding}") if values.ndim != 2 or (n_ao and values.shape[1] != n_ao): raise QVFError("MO coefficients must have shape [n_mo, n_ao]") if not np.isfinite(values).all(): raise QVFError("MO coefficients must be finite") return values coeffs = _decode_coefficients(coeffs) a_c = _decode_coefficients(a_c) b_c = _decode_coefficients(b_c) # The metadata cannot list more orbitals than the coefficient matrix # holds: the orbital animation counted them from the energies and # indexed past a [0, n_ao] matrix (#66). A shorter prefix stays allowed, # as for trajectory energies. for matrix, name, vectors in ( (coeffs, "mo_coefficients", (("energies", energies), ("occupations", occupations))), (a_c, "mo_coefficients_alpha", (("alpha.energies", a_e), ("alpha.occupations", a_o))), (b_c, "mo_coefficients_beta", (("beta.energies", b_e), ("beta.occupations", b_o))), ): if matrix is None: continue for vector_name, vector in vectors: if vector is not None and len(vector) > matrix.shape[0]: raise QVFError( f"{vector_name} lists {len(vector)} orbitals but {name} has " f"{matrix.shape[0]} rows" ) # Shells that span more AOs than the coefficients are wide made the # renderer raise "MO vector too short" on the first evaluation (#66). n_ao_shells = sum(_shell_n_ao(shell.l, shell.pure) for shell in shells) width = n_ao or next((int(m.shape[1]) for m in (coeffs, a_c, b_c) if m is not None), 0) if width and n_ao_shells > width: what = f"n_ao is {n_ao}" if n_ao else f"the MO coefficients have width {width}" raise QVFError(f"basis shells span {n_ao_shells} AOs but {what}") k_point = meta_raw.get("k_point") if k_point is not None: k_point = np.asarray(k_point, dtype=float) if k_point.shape != (3,) or not np.isfinite(k_point).all(): raise QVFError("k_point must contain three finite numbers") def _optional_array(key: str, dtype: type[np.generic]) -> np.ndarray | None: raw = meta_raw.get(key) if raw is None: return None arr = np.asarray(raw, dtype=dtype) if arr.ndim == 0: # Per-orbital data; a bare number made every consumer's len() fail. raise QVFError(f"{key} must be a list") return arr if arr.size else None return WavefunctionGTOData( atom_populations=_optional_array("atom_populations", np.float64), centroids_bohr=_optional_array("centroids_bohr", np.float64), n_centres=_optional_array("n_centres", np.int64), localization_method=( _str_or_none(meta_raw.get("localization_method")) ), occupation_semantics=occupation_semantics, density_unavailable_reason=_str_or_none(meta_raw.get("density_unavailable_reason")), transition_orbitals=transition_orbitals, k_point=k_point, structure_ref=structure_ref, pure=global_pure, n_ao=n_ao, shells=shells, spin=spin, orbital_kind=orbital_kind, energies=energies, occupations=occupations, symmetry_labels=sym_labels, alpha_energies=a_e, alpha_occupations=a_o, beta_energies=b_e, beta_occupations=b_o, mo_coefficients=coeffs, mo_coefficients_alpha=a_c, mo_coefficients_beta=b_c, symmetry_labels_beta=sym_labels_beta, ) # ── QVFReader ───────────────────────────────────────────────────────────── QVFSource = str | Path | bytes | bytearray | memoryview | IO[bytes] """Anything `QVFReader` can open: a filesystem path, raw zip bytes, or a seekable binary file-like object (BytesIO, an opened file, etc.). Used by vibe-qc to hand a freshly built QVF over to the viewer without touching disk."""
[docs] class QVFReader: """Read a .qvf archive and provide lazy access to sections. The archive can come from any of: * a filesystem path (``str`` / :class:`pathlib.Path` ending in ``.qvf``) * raw zip bytes (``bytes`` / ``bytearray`` / ``memoryview``) — vibe-qc can build a QVF in memory via ``qvf_bytes(...)`` and pass them in * a seekable binary file-like object (``BytesIO``, opened binary file, …) The zip handle is kept open for the lifetime of the reader so lazy binary blobs can be read on demand. ``close()`` (or use as a context manager) releases it; if the reader owns an in-memory buffer that is released too. """ def __init__(self, source: QVFSource) -> None: self._path: Path | None self._buffer: io.BytesIO | None = None # Loose-file conversion keeps the archive in memory. Remember its # origin separately so the UI can identify an already-open dataset # without pretending the original file is an on-disk QVF to watch. # This is session state only and is never written into an archive. self.source_path: Path | None = None if isinstance(source, (str, Path)): self._path = Path(source) try: exists = self._path.exists() except OSError as e: raise QVFOpenError(f"cannot access {self._path}: {e}") from None if not exists: raise QVFOpenError(f"file not found: {self._path}") # Case-insensitive: ``.QVF`` from a case-preserving filesystem # or a Windows share is the same format (#35-36). if self._path.suffix.lower() != ".qvf": raise QVFOpenError(f"expected .qvf extension, got: {self._path.suffix}") if self._path.is_dir(): raise QVFOpenError(f"not a file but a directory: {self._path}") self._zf = self._open_zip(self._path) elif isinstance(source, (bytes, bytearray, memoryview)): self._path = None self._buffer = io.BytesIO(bytes(source)) try: self._zf = self._open_zip(self._buffer) except QVFOpenError: self._buffer.close() self._buffer = None raise elif hasattr(source, "read"): # Generic seekable file-like self._path = None self._zf = self._open_zip(source) else: raise QVFOpenError( f"QVFReader: unsupported source type {type(source).__name__}; " "expected path, bytes, or seekable file-like object" ) # From here on the reader owns an open ZipFile: release it on ANY # failure, so a constructor that raises never leaves a handle behind # for the garbage collector (#39-18). try: self._load_manifest() except QVFError: self.close() raise except Exception as e: # Structural guarantee (#39-35): a manifest that parses but has a # shape no check above anticipated must still fail as a QVFError, # never as whatever the code that tripped over it raised # (OverflowError from ``int(inf)``, TypeError from an unhashable # kind, ...). Callers handle QVFError; anything else escaped as a # traceback from every command. KeyboardInterrupt and SystemExit # are not Exceptions and pass through the clause below. self.close() if getattr(self, "_manifest_text", None) is None: raise QVFOpenError( f"cannot read manifest.json: {_describe_exception(e)}" ) from e raise ManifestValidationError( f"manifest.json has an unsupported shape: {_describe_exception(e)}" ) from e except BaseException: self.close() raise @staticmethod def _open_zip(target: Path | IO[bytes]) -> zipfile.ZipFile: """``ZipFile(target)``, with every open failure as ``QVFOpenError``.""" try: return zipfile.ZipFile(target, "r") except zipfile.BadZipFile as e: raise QVFOpenError(f"not a valid zip archive: {e}") from None except _ARCHIVE_READ_ERRORS as e: raise QVFOpenError(f"cannot open archive: {e}") from None except Exception as e: # E.g. a file-like source whose read/seek misbehaves (#39-35). raise QVFOpenError(f"cannot open archive: {_describe_exception(e)}") from e def _load_manifest(self) -> None: """Parse, validate and index manifest.json of the opened archive.""" # ── Step 1: extract + parse manifest.json ── try: raw = self._zf.read("manifest.json").decode("utf-8") except KeyError: self.close() raise QVFOpenError("manifest.json not found in archive") from None except UnicodeDecodeError as e: self.close() raise QVFOpenError(f"manifest.json is not valid UTF-8: {e}") from None except _ARCHIVE_READ_ERRORS as e: self.close() raise QVFOpenError(f"cannot read manifest.json: {e}") from None try: manifest_dict = json.loads(raw) except json.JSONDecodeError as e: self.close() raise QVFOpenError(f"manifest.json is not valid JSON: {e}") from None except _JSON_PARSE_ERRORS as e: # A deeply nested document exhausts the parser's recursion limit # (RecursionError) instead of raising JSONDecodeError (#39-35). self.close() raise QVFOpenError( f"manifest.json cannot be parsed: {type(e).__name__}: {e}" ) from None if _json_depth_exceeds(manifest_dict, _MAX_JSON_DEPTH): # Parseable, but every later consumer that walks it recursively # (schema messages, json.dumps in h-add/slice/merge, the info # views) would hit RecursionError instead (#39-35). self.close() raise QVFOpenError(f"manifest.json nests deeper than {_MAX_JSON_DEPTH} levels") self._manifest_text = raw if not isinstance(manifest_dict, dict): # A JSON array or scalar used to reach ``.get`` below and escape # as AttributeError from every command but ``open`` (#39-35). self.close() raise ManifestValidationError( f"manifest.json must be a JSON object, got {type(manifest_dict).__name__}" ) # Before the number check and the schema, whose messages quote keys # and values: a message holding a lone surrogate cannot be printed. bad_string = _lone_surrogate_in(raw, manifest_dict) if bad_string is not None: self.close() raise ManifestValidationError( "manifest.json has a string with an unpaired UTF-16 surrogate " f"at {bad_string}, which is not Unicode text" ) bad_number = _first_nonfinite_number(manifest_dict) if bad_number is not None: where, what = bad_number self.close() raise ManifestValidationError( f"manifest.json has a number that is not a finite double at {where}: {what}" ) # ── Step 2: validate against JSON Schema ── # Pick the schema by the manifest's declared qvf_version. The # withdrawn version 2 is read as version 1 for every check, the # schema included, and leaves the warning W_VERSION_WITHDRAWN # (QVF spec § 3.1), as qvf's own validators do. Any other version # is reported as unsupported instead of mis-validating against v1. # Non-finite numbers are refused above; lists, dicts and strings make # ``int()`` raise TypeError/ValueError, and OverflowError stays # listed in case that check ever moves. Each falls back to v1, whose # schema then rejects the value (#39-35). self._manifest_warnings: list[str] = [] raw_version = manifest_dict.get("qvf_version", 1) if raw_version == _WITHDRAWN_VERSION and not isinstance(raw_version, bool): read_version = _WITHDRAWN_READ_AS manifest_dict_checked = {**manifest_dict, "qvf_version": read_version} self._manifest_warnings.append( f"W_VERSION_WITHDRAWN: qvf_version {_WITHDRAWN_VERSION} is withdrawn; " f"read as version {read_version} (QVF spec § 3.1)" ) else: try: read_version = int(raw_version) except (TypeError, ValueError, OverflowError): read_version = 1 manifest_dict_checked = manifest_dict # The version the archive is read as; payload checks use its schema. self._read_version = read_version try: chosen_schema = _load_manifest_schema(read_version) except ManifestValidationError: self.close() raise # ── Step 2a: enforce the `critical` contract BEFORE tolerating # unknown kinds. Any section flagged `critical: true` whose kind the # consumer cannot act on MUST block the open — including vendor # `x_<vendor>.*` kinds, which are never renderable (QVF spec §5.3: # vendor sections are listed-but-skipped *unless* critical; §5.5 / §7: # an unsupported critical section MUST refuse the open). A critical # root extension (below) blocks the open too. usable = _consumer_usable_kinds() raw_sections = manifest_dict.get("sections") if isinstance(raw_sections, list): for sec in raw_sections: if not isinstance(sec, dict): continue kind = sec.get("kind") # ``kind`` is unvalidated here: a list or dict would make the # set lookup raise TypeError (unhashable) (#39-35). A critical # section whose kind is not even a string is unsupported. if sec.get("critical") is True and not (isinstance(kind, str) and kind in usable): self.close() raise CriticalSectionUnsupportedError( f"section {sec.get('id')!r} is marked critical but its " f"kind {kind!r} is not supported by vibe-view; refusing " "to open (a partial render would be misleading)." ) extensions = manifest_dict.get("extensions") if isinstance(extensions, dict): for ext_name, ext in extensions.items(): if isinstance(ext, dict) and ext.get("critical") is True: self.close() raise CriticalSectionUnsupportedError( f"manifest declares critical extension {ext_name!r} that " "vibe-view does not support; refusing to open." ) # ── Step 2b: validate, tolerating schema-unknown section kinds. # The schema constrains `kind` to a fixed set of consts; a forward- # compat / reserved kind the writer might add later (fermi_surface, # phonon_bands, …) would otherwise fail the whole-manifest validation # and lose every section, including structure (audit A3-01). We set # those sections aside, validate the remainder, and still open — # surfacing the unknown sections as "skipped, unsupported". known_kinds = _schema_known_kinds(chosen_schema) if isinstance(raw_sections, list): kept, unknown = [], [] for sec in raw_sections: kind = sec.get("kind") if isinstance(sec, dict) else None is_vendor = isinstance(kind, str) and kind.startswith("x_") if isinstance(kind, str) and kind not in known_kinds and not is_vendor: # The schema requires ``members`` on every section (QVF # spec § 4.1), but it never sees an unknown kind, and the # model defaults a missing map to {}. ``merge`` then # failed on the manifest copy with KeyError (#39-35). if not isinstance(sec.get("members"), dict): self.close() raise ManifestValidationError( f"section {sec.get('id')!r} of kind {kind!r} has no 'members' " "object; every section needs one (QVF spec § 4.1)" ) unknown.append(sec) else: kept.append(sec) to_validate = ( manifest_dict_checked if not unknown else {**manifest_dict_checked, "sections": kept} ) else: to_validate = manifest_dict_checked try: _validate_manifest(to_validate, read_version, chosen_schema) # The model still checks the common shape of unknown sections; # retain their existing tolerance for future identifier conventions. self._manifest = Manifest.model_validate(manifest_dict) except (jsonschema.ValidationError, ValidationError) as e: self.close() raise ManifestValidationError(_validation_message(e)) from None except RecursionError: # A manifest nested just shallowly enough to parse can still # exhaust the validator's recursion (#39-35). self.close() raise ManifestValidationError( "manifest.json is nested too deeply to validate" ) from None # Build section lookup self._section_by_id: dict[str, Section] = {} for s in self._manifest.sections: if s.id in self._section_by_id: self.close() raise ManifestValidationError(f"duplicate section id in manifest: {s.id!r}") self._section_by_id[s.id] = s # First member failure per section; archive bytes are immutable. self._section_errors: dict[str, _SectionError] = {} # Cache validation success, not mutable decoded geometry. Each read # still verifies the member bytes and creates fresh atoms/arrays. # Validated structure payloads, keyed by (section, member sha256, # qvf_version): the JSON is parsed and validated once per archive # content, not on every read_structure. Treated as read-only. self._structure_payloads: dict[tuple[str, str, int], dict[str, Any]] = {} # Decoded-binary cache, keyed (section_id, member_name). The archive # is opened read-only and members are immutable for the reader's # lifetime, so re-reading a member can return the cached ndarray # instead of re-DEFLATE-decompressing + re-SHA-256-hashing it. This # is what makes the isovalue / clip sliders and trajectory/vibration # playback responsive — each previously re-read the whole blob on # every tick because the renderer (and its cache) was rebuilt each # call (audit findings A7-01 / A7-03). frombuffer arrays are already # read-only, so sharing them is safe. self._binary_cache: dict[tuple[str, str], np.ndarray] = {} self._validated_frame_arrays: dict[tuple[str, str], np.ndarray] = {} # Viewer-side edit overlay: the current *edited* geometry, or None # when the archive is unmodified. The archive itself is immutable; # edits (add/delete/move atoms, fragments, supercell, …) live here # so every consumer of read_structure() — edit handlers, renderers, # export, input generation — sees the same current structure and # sequential edits compound instead of each one resetting to the # file's pristine geometry. A supercell edit can also replace the # lattice; keeping that beside the atoms makes the edit one atomic # structure overlay rather than an out-of-cell atom list paired with # the archive's old unit cell. A fresh reader starts with no overlay; # the controller may explicitly migrate one across a panel-only hot # reload when the unchanged 3D scene still represents that edit. # # Keyed by structure section id: an edit belongs to the structure it # was made on (QVF spec § 4.1: an archive may hold several), so # showing another structure shows that one as the file has it, and # showing the edited one again shows the edit. Each entry is the # edited atoms and the replacing lattice (None keeps the file's). self._edit_overlays: dict[str, tuple[list[Atom], np.ndarray | None, list[Bond] | None]] = {} # The structure a consumer that shows one structure shows; None # means the first structure (see :attr:`shown_structure_id`). self._shown_structure_id: str | None = None # Viewer-computed wavefunctions, keyed by a synthetic section id. # Same contract as ``_edit_overlays``: it shadows the archive for the # lifetime of this reader, and a fresh reader starts empty so # re-reading the file always wins. Used by the re-localize feature # (:mod:`vibeview.relocalize`), which asks vibe-qc for a # localization criterion the file does not contain. self._wavefunction_overlays: dict[str, WavefunctionGTOData] = {} self.geometry_revision = 0 @property def manifest(self) -> Manifest: return self._manifest @property def path(self) -> Path | None: """Path to the .qvf file on disk, or ``None`` for in-memory archives.""" return self._path @property def source(self) -> Source: return self._manifest.source @property def sections(self) -> list[Section]: return self._manifest.sections @property def viewer_defaults(self) -> ViewerDefaults | None: return self._manifest.viewer_defaults # ── live / streaming checkpoint fields (consumer_qvf_reference.md # § "Live / streaming checkpoints") ─────────────────────────────── @property def provenance(self) -> dict[str, Any]: """The manifest's open ``provenance`` block ({} when absent).""" extra = self._manifest.model_extra or {} prov = extra.get("provenance") return prov if isinstance(prov, dict) else {}
[docs] def scf_energy_eh(self) -> float | None: """The provenance's SCF energy in hartree, or None when it records none.""" return scf_energy_eh(self.provenance)
@property def dipole_moment(self) -> dict[str, Any]: """The manifest's root ``dipole_moment`` block ({} when absent). **Root, not ``provenance``** -- QVF spec § 4.7 puts it there and the schema declares it as a root property. Reading it off ``provenance`` silently yields ``None`` for every file ever written, which is what left the dipole arrow and the dipole readout dead. Keys: ``total_debye``, ``vector_debye`` (3), ``origin`` (3). """ extra = self._manifest.model_extra or {} value = extra.get("dipole_moment") return value if isinstance(value, dict) else {} @property def thermochemistry(self) -> dict[str, Any]: """The manifest's root ``thermochemistry`` block ({} when absent). Root, for the same reason as :attr:`dipole_moment`. Producer keys (``python/vibeqc/runner.py``) are ``zpve_eh``, ``enthalpy_eh``, ``entropy_cal_mol_k``, ``gibbs_free_energy_eh``, ``temperature_k`` and ``pressure_atm``. Note the entropy is **cal/mol/K**, not Eh/K -- the one field here whose unit is not atomic. """ extra = self._manifest.model_extra or {} value = extra.get("thermochemistry") return value if isinstance(value, dict) else {} @property def run_status(self) -> str | None: """The archive's place in a calculation lifecycle (spec § 3.2): ``"pending"`` for a job container that has not yet run, ``"running"`` while the job is in flight (live checkpoint), then ``"converged"`` or ``"failed"``. None when not stated (an ordinary results archive).""" v = self.provenance.get("run_status") return v if isinstance(v, str) else None @property def checkpoint_info(self) -> dict[str, Any]: """``provenance.checkpoint`` ({} when absent): ``seq`` (monotonic), ``wall_time_s``, ``written_at``, optionally ``scf_iteration`` / ``energy_eh`` on mid-SCF snapshots.""" v = self.provenance.get("checkpoint") return v if isinstance(v, dict) else {}
[docs] def run_record_sections(self) -> list[Section]: """All ``run.record`` sections in run-history order. Ordered by their ``sequence`` field (spec § 5.8; a record without one sorts before sequenced records, keeping manifest order among themselves). The **last** entry is the latest run — the one whose results the archive's other sections describe. """ records = [s for s in self.sections if s.kind == "run.record"] def _seq(item: tuple[int, Section]) -> tuple[int, int]: index, section = item extras = getattr(section, "model_extra", None) or {} seq = extras.get("sequence") if isinstance(seq, int): return (seq, index) return (-1, index) return [s for _, s in sorted(enumerate(records), key=_seq)]
[docs] def run_record_position(self, section_id: str) -> tuple[int, int]: """(1-based position, total) of a run.record in run history. Position ``total`` is the latest run. ``(0, 0)`` when the section is not a run.record of this archive. """ history = self.run_record_sections() for i, section in enumerate(history): if section.id == section_id: return i + 1, len(history) return 0, 0
[docs] def validation_report(self, *, limits: dict[str, Any] | None = None) -> dict[str, Any]: """Validate the open archive with qvf; preserve its handle and edit overlay.""" from vibeview.qvf_validation import validate_archive stream = self._zf.fp if stream is None: raise QVFOpenError("cannot validate a closed reader") return validate_archive(stream, limits=limits)
[docs] def manifest_warnings(self) -> list[str]: """Non-fatal findings about the manifest, made when the archive opened. Each warning starts with its QVF warning code. The one there is today is ``W_VERSION_WITHDRAWN``: the manifest declares the withdrawn ``qvf_version`` 2, which vibe-view reads and checks as version 1 (spec § 3.1). The archive is valid; the warning records that its producer wrote a version producers must not write. Empty for an archive with nothing to report. """ return list(self._manifest_warnings)
[docs] def lifecycle_warnings(self) -> list[str]: """Container-lifecycle consistency warnings (spec § 3.2, § 5.9). "Done" means a **terminal** ``run_status`` (``converged`` / ``failed``) *plus* a complete latest ``run.record`` — one that carries both its ``input`` and ``log`` members (an empty log is complete: a run that failed before output began legitimately logged zero bytes). Returns human-readable warnings, empty when the archive is consistent. Non-terminal and unstated statuses never warn. """ status = self.run_status if status not in ("converged", "failed"): return [] warnings: list[str] = [] history = self.run_record_sections() if not history: warnings.append( f"terminal status '{status}' but the archive carries no " f"run.record — the run that settled it left no record" ) return warnings latest = history[-1] missing = [ role for role in ("input", "log") if role not in latest.members ] if missing: warnings.append( f"terminal status '{status}' but the latest run.record " f"({latest.id}) lacks its {' and '.join(missing)} " f"member{'s' if len(missing) > 1 else ''} — the record " f"of this run is incomplete" ) return warnings
[docs] def section_is_partial(self, section_id: str) -> bool: """True when the producer marked this section ``partial`` — still growing in a live checkpoint (e.g. an optimization trajectory).""" section = self._section_by_id.get(section_id) if section is None: return False extras = getattr(section, "model_extra", None) or {} return extras.get("partial") is True
[docs] def has_section(self, section_id: str) -> bool: return section_id in self._section_by_id
[docs] def get_section(self, section_id: str) -> Section: try: return self._section_by_id[section_id] except KeyError: raise SectionNotFoundError( f"section not found: {section_id!r}; {self.describe_sections()}" ) from None
[docs] def describe_sections(self, limit: int = 12) -> str: """The section ids and kinds, for a message about a wrong id (#149). ``show -s``, ``capture -s`` and the other ``--section`` options reported a bare "not found", leaving ``vibe-view info`` as the only way to learn the right id. Long archives are cut at ``limit``. """ sections = self.sections if not sections: return "this file has no sections" listed = ", ".join(f"{s.id} ({s.kind})" for s in sections[:limit]) more = len(sections) - limit return f"sections in this file: {listed}" + (f", and {more} more" if more > 0 else "")
# ── Structures: found by kind and reference, never by id ────────────── # # QVF spec § 4.1: section ids carry no meaning. ``structure`` is only the # id the reference writers use by default; an archive may hold several # structures under any ids (vibe-sketch writes ``structure_m1``, ...). # A consumer that shows one structure, or needs one for an operation # that names none, uses the *first structure*. def _is_structure(self, section_id: object) -> bool: section = self._section_by_id.get(section_id) if isinstance(section_id, str) else None return section is not None and section.kind == "structure" def _named_structure(self, section: Section) -> str | None: """The section's own ``structure_ref`` when it names a structure, else None.""" reference = (section.model_extra or {}).get("structure_ref") return reference if self._is_structure(reference) else None @property def structure_sections(self) -> list[Section]: """Every ``structure`` section, in ``sections`` order.""" return [s for s in self._manifest.sections if s.kind == "structure"] @property def first_structure_id(self) -> str | None: """The archive's first structure (QVF spec § 4.1), or None without one. The first ``structure`` section that ``viewer_defaults.auto_open`` lists, else the first section of kind ``structure`` in ``sections`` order. """ defaults = self._manifest.viewer_defaults for section_id in defaults.auto_open if defaults is not None else (): if self._is_structure(section_id): return section_id return next((s.id for s in self._manifest.sections if s.kind == "structure"), None) @property def shown_structure_id(self) -> str | None: """The structure shown and used where none is named, or None without one. The first structure until :meth:`show_structure` picks another. ``read_structure()`` without an id reads it, and every consumer that shows or exports "the structure" goes through that. """ if self._shown_structure_id is not None and self._is_structure(self._shown_structure_id): return self._shown_structure_id return self.first_structure_id @property def shown_structure(self) -> Section | None: """The section of :attr:`shown_structure_id`, or None.""" section_id = self.shown_structure_id return None if section_id is None else self._section_by_id[section_id]
[docs] def show_structure(self, section_id: str | None) -> None: """Make ``section_id`` the shown structure; None returns to the first one. Raises :class:`SectionNotFoundError` for an unknown id and :class:`QVFError` for a section of another kind, leaving the choice unchanged. An edit made on a structure stays with it (see :meth:`set_edit_overlay`). Changing the choice bumps :attr:`geometry_revision`, since ``read_structure()`` now returns other atoms. """ if section_id is not None: section = self.get_section(section_id) if section.kind != "structure": raise QVFError(f"section {section_id!r} is a {section.kind!r}, not a structure") previous = self.shown_structure_id self._shown_structure_id = section_id if self.shown_structure_id != previous: self.geometry_revision += 1
[docs] def structure_ref_of(self, section_id: str) -> str | None: """The structure a section describes (QVF spec § 4.1), or None. A structure describes itself. Any other section describes the structure its ``structure_ref`` names; one without a usable reference makes no statement, and the reader keeps its usual behaviour of relating it to the first structure. """ section = self.get_section(section_id) if section.kind == "structure": return section.id return self._named_structure(section) or self.first_structure_id
[docs] def named_structure_ref(self, section_id: str) -> str | None: """The structure a section's own ``structure_ref`` names, or None. Unlike :meth:`structure_ref_of`, this never falls back to the first structure: a section without a usable reference makes no statement about which structure it describes (QVF spec § 4.1), and a viewer must not switch structures on a guess (#106). A structure names itself. """ section = self.get_section(section_id) if section.kind == "structure": return section.id return self._named_structure(section)
[docs] def structure_identity(self, section_id: str | None = None) -> StructureIdentity: """The chemical identity a ``structure`` section object states (spec § 5.1). ``section_id`` defaults to the shown structure. Without a structure, or for fields of the wrong type, the identity states nothing. """ section_id = self.shown_structure_id if section_id is None else section_id if section_id is None or not self._is_structure(section_id): return StructureIdentity() section = self._section_by_id[section_id] extra = section.model_extra or {} def text(key: str) -> str | None: value = extra.get(key) return value if isinstance(value, str) and value.strip() else None def integer(key: str) -> int | None: value = extra.get(key) return value if isinstance(value, int) and not isinstance(value, bool) else None multiplicity = integer("multiplicity") return StructureIdentity( label=text("label"), name=text("name"), charge=integer("charge"), multiplicity=multiplicity if multiplicity is None or multiplicity >= 1 else None, smiles=text("smiles"), inchi=text("inchi"), inchikey=text("inchikey"), )
[docs] def declared_charge_multiplicity( self, section_id: str | None = None ) -> tuple[int | None, int | None]: """The charge and multiplicity the archive states for a structure. Each from the structure's identity (spec § 5.1, ``section_id`` defaulting to the shown structure), else from ``provenance`` (§ 3.2) when the archive has at most one structure section: with several, the provenance describes none of them in particular. A missing, non-integer or (for multiplicity) non-positive value is None. """ identity = self.structure_identity(section_id) charge, multiplicity = identity.charge, identity.multiplicity if len(self.structure_sections) <= 1: provenance = self.provenance def integer(key: str) -> int | None: value = provenance.get(key) return value if isinstance(value, int) and not isinstance(value, bool) else None if charge is None: charge = integer("charge") if multiplicity is None: stated = integer("multiplicity") multiplicity = stated if stated is None or stated >= 1 else None return charge, multiplicity
[docs] def section_error(self, section_id: str) -> str | None: """Return the first recorded member error for this section, or None.""" error = self._section_errors.get(section_id) return error.message if error is not None else None
[docs] def section_error_kind(self, section_id: str) -> str | None: """Return the recorded error category for a section, or None.""" error = self._section_errors.get(section_id) return error.kind if error is not None else None
def _record_section_error( self, section_id: str, error: QVFError, kind: str = "invalid payload" ) -> None: if isinstance(error, SHA256MismatchError): kind = "sha256 mismatch" self._section_errors.setdefault(section_id, _SectionError(str(error), kind)) # ── sha256 verification (Rule 4) ────────────────────────────────── def _verify_and_read(self, member: MemberSpec) -> bytes: """Read a member from the zip and verify its sha256. Returns the raw bytes. Raises SHA256MismatchError on mismatch. """ data = self.read_member_bytes(member.path) actual = hashlib.sha256(data).hexdigest() if actual != member.sha256: raise SHA256MismatchError( f"sha256 mismatch for {member.path!r}: expected {member.sha256}, got {actual}" ) return data
[docs] def read_member_bytes(self, path: str) -> bytes: """The raw bytes of zip entry ``path``, unverified. Every failure to read the entry is a ``QVFError`` naming it: a missing entry, a CRC or deflate error, an encrypted entry or an unsupported compression method. Only ``BadZipFile`` was mapped before, so the rest reached the CLI as tracebacks (#39-35). """ try: return self._zf.read(path) except KeyError: raise QVFError( f"member file {path!r} declared in manifest but missing from zip" ) from None except _ARCHIVE_READ_ERRORS as e: raise QVFError(f"cannot read zip member {path!r}: {e}") from None
[docs] def manifest_json(self) -> dict[str, Any]: """A fresh copy of manifest.json as parsed JSON, unknown fields included. The reader parsed it once while opening, so this does not read the archive again (and cannot fail on it). """ return cast(dict[str, Any], json.loads(self._manifest_text))
def _read_member( self, section_id: str, member_name: str, *, expect_format: str | None = None ) -> bytes: """Read verified member bytes and record failures for every section kind. ``expect_format`` checks the manifest's declared ``format`` before the bytes are decoded, so a member declared ``binary`` is never parsed as JSON and vice versa (#35-27). The schema pins the format of every canonical member, but vendor sections are unpinned. """ section = self.get_section(section_id) try: member = section.members.get(member_name) if member is None: raise QVFError(f"member {member_name!r} not found in section {section_id!r}") if expect_format is not None and member.format != expect_format: raise QVFError( f"member {member.path!r} in section {section_id!r} is declared " f"format {member.format!r}, but is read as {expect_format!r}" ) return self._verify_and_read(member) except QVFError as error: self._record_section_error(section_id, error, "member read error") raise def _read_text_member( self, section_id: str, member_name: str, *, errors: str = "strict", expect_format: str | None = None, ) -> str: raw = self._read_member(section_id, member_name, expect_format=expect_format) try: return raw.decode("utf-8", errors=errors) except UnicodeDecodeError as error: member = self.get_section(section_id).members[member_name] failure = QVFError( f"member {member.path!r} in section {section_id!r} is not UTF-8: {error}" ) self._record_section_error(section_id, failure) raise failure from None def _read_json_member(self, section_id: str, member_name: str) -> Any: """Read + verify a JSON member. Returns parsed JSON.""" raw = self._read_text_member(section_id, member_name, expect_format="json") try: value = json.loads(raw) except _JSON_PARSE_ERRORS as error: member = self.get_section(section_id).members[member_name] failure = QVFError( f"member {member.path!r} in section {section_id!r} is not valid JSON: {error}" ) self._record_section_error(section_id, failure) raise failure from None if _json_depth_exceeds(value, _MAX_JSON_DEPTH): member = self.get_section(section_id).members[member_name] failure = QVFError( f"member {member.path!r} in section {section_id!r} nests deeper than " f"{_MAX_JSON_DEPTH} levels" ) self._record_section_error(section_id, failure) raise failure bad_string = _lone_surrogate_in(raw, value) if bad_string is not None: member = self.get_section(section_id).members[member_name] failure = QVFError( f"member {member.path!r} in section {section_id!r} has a string with an " f"unpaired UTF-16 surrogate at {bad_string}, which is not Unicode text" ) self._record_section_error(section_id, failure) raise failure return value def _read_binary_member(self, section_id: str, member_name: str) -> np.ndarray: """Read + verify a binary member. Returns numpy array (cached).""" cache_key = (section_id, member_name) cached = self._binary_cache.get(cache_key) if cached is not None: return cached raw = self._read_member(section_id, member_name, expect_format="binary") member = self.get_section(section_id).members[member_name] dtype = member.dtype or "float32" try: arr = np.frombuffer(raw, dtype=np.dtype(dtype)) if member.shape is not None: arr = arr.reshape(member.shape) except ValueError as e: error = QVFError( f"member {member.path!r} in section {section_id!r} cannot be " f"read as dtype={dtype!r}, shape={member.shape!r}: {e}" ) self._record_section_error(section_id, error) raise error from None self._binary_cache[cache_key] = arr return arr
[docs] def stored_size(self, member: MemberSpec) -> int | None: """Uncompressed size in bytes of ``member`` in the archive, or None if absent.""" try: return int(self._zf.getinfo(member.path).file_size) except KeyError: return None
[docs] def cached_bytes(self) -> dict[str, int]: """Bytes of decoded binary members this reader holds, per section id. The binary cache is where a volume's or trajectory's arrays live once read, so this is the figure a memory readout wants (#35-12); the wrapper objects around it are a few dozen bytes. """ usage: dict[str, int] = {} for (section_id, _member), arr in self._binary_cache.items(): usage[section_id] = usage.get(section_id, 0) + int(arr.nbytes) return usage
[docs] def drop_cached(self, section_id: str) -> int: """Release the cached arrays of one section; return the bytes released. The next read decodes the member from the archive again. Arrays a caller still references stay alive until the caller lets go. """ freed = 0 for key in [k for k in self._binary_cache if k[0] == section_id]: freed += int(self._binary_cache.pop(key).nbytes) for key in [k for k in self._validated_frame_arrays if k[0] == section_id]: del self._validated_frame_arrays[key] return freed
# ── Eager data extraction ─────────────────────────────────────────
[docs] def set_edit_overlay( self, positions: np.ndarray | Sequence[Sequence[float]], symbols: Sequence[str], *, lattice_vectors: np.ndarray | Sequence[Sequence[float]] | None = None, section_id: str | None = None, bonds: Sequence[Bond] | None = None, ) -> None: """Record viewer-side edited geometry (see ``_edit_overlays``). ``positions`` is an (n, 3) sequence in angstroms, ``symbols`` the matching element symbols. Raises ``ValueError``, leaving the current overlay untouched, for a wrong shape, a non-finite coordinate, a symbol outside the element table, or an archive without the structure. Subsequent :meth:`read_structure` calls for that structure return this geometry instead of the archive's. ``bonds=None`` requests inferred connectivity; an empty sequence explicitly removes all bonds. Supplied bonds must use the edited atom indices. The edit belongs to ``section_id``, by default the shown structure, and stays with it while another structure is shown. ``lattice_vectors`` replaces the current edit-overlay lattice when supplied. Omitting it preserves the current lattice, which lets ordinary atom edits made after a supercell build keep that expanded cell. The lattice is reset together with the atoms by :meth:`clear_edit_overlay`. """ target = self.shown_structure_id if section_id is None else section_id if target is None or not self._is_structure(target): raise ValueError( "no structure section to edit" if target is None else f"section {target!r} is not a structure section" ) lattice = None if lattice_vectors is not None: lattice = np.asarray(lattice_vectors, dtype=np.float64) if lattice.shape != (3, 3) or not np.all(np.isfinite(lattice)): raise ValueError("lattice_vectors must be a finite 3x3 matrix") # DegenerateLatticeError is a ValueError. A singular cell keeps the # bond search's single-shell fallback. with contextlib.suppress(np.linalg.LinAlgError): interplanar_spacings(lattice) coords = np.asarray(positions, dtype=np.float64) symbol_list = [str(sym) for sym in symbols] # Validate before touching any state (#35-13). An (n, 3) finite array # only: a 2-component position used to be stored and crashed later in # a renderer. An unknown symbol used to become Z=0, which the bond # rule treats as a metal with the 1.5 A fallback radius, so one typo # could switch metal-metal bonding for the whole structure. if coords.ndim != 2 or coords.shape[1:] != (3,) or len(coords) != len(symbol_list): raise ValueError( f"positions must be an (n, 3) array matching {len(symbol_list)} symbols, " f"got shape {coords.shape}" ) if not np.all(np.isfinite(coords)): raise ValueError("positions must be finite") unknown = sorted({sym for sym in symbol_list if sym not in SYMBOL_TO_Z}) if unknown: raise ValueError(f"unknown element symbol(s) {unknown}") atoms = [ Atom(symbol=sym, position=coords[i].copy(), atomic_number=SYMBOL_TO_Z[sym]) for i, sym in enumerate(symbol_list) ] edited_bonds: list[Bond] | None = None if bonds is not None: edited_bonds = [] for i, j, order, image in bonds: if (isinstance(i, bool) or isinstance(j, bool) or not isinstance(i, (int, np.integer)) or not isinstance(j, (int, np.integer)) or min(i, j) < 0 or max(i, j) >= len(atoms) or not math.isfinite(order) or order <= 0 or len(image) != 3 or any(isinstance(v, bool) or not isinstance(v, (int, np.integer)) for v in image) or (i == j and not any(image))): raise ValueError("invalid explicit edit-overlay bond") edited_bonds.append((int(i), int(j), float(order), (int(image[0]), int(image[1]), int(image[2])))) previous = self._edit_overlays.get(target) if lattice is not None: edited_lattice: np.ndarray | None = lattice.copy() else: edited_lattice = previous[1] if previous is not None else None self._edit_overlays[target] = (atoms, edited_lattice, edited_bonds) self.geometry_revision += 1 self.clear_wavefunction_overlays( [sid for sid in self.wavefunction_overlay_ids if sid.startswith(RELOCALIZED_PREFIX)] )
[docs] def clear_edit_overlay(self, section_id: str | None = None) -> None: """Drop viewer-side edits; read_structure() returns the file again. ``section_id`` drops only that structure's edit; by default every structure's edit is dropped. """ if section_id is None: self._edit_overlays.clear() else: self._edit_overlays.pop(section_id, None) self.geometry_revision += 1 self.clear_wavefunction_overlays( [sid for sid in self.wavefunction_overlay_ids if sid.startswith(RELOCALIZED_PREFIX)] )
@property def has_edit_overlay(self) -> bool: """Whether any structure carries viewer-side edits.""" return bool(self._edit_overlays) @property def edited_structure_ids(self) -> list[str]: """The structure sections that carry viewer-side edits, in edit order.""" return list(self._edit_overlays)
[docs] def set_wavefunction_overlay( self, section_id: str, wavefunction: WavefunctionGTOData ) -> None: """Register a viewer-computed wavefunction under ``section_id``. The id also gets a synthetic :class:`Section` so the sidebar and every ``reader.sections`` consumer see it exactly like an archived one. The section carries no members: nothing will read them, because :meth:`read_wavefunction_gto` short-circuits on the overlay first. Registering the same id twice replaces the payload and leaves the single synthetic section in place, so re-running a criterion does not accumulate sidebar rows. """ self._wavefunction_overlays[section_id] = wavefunction if section_id not in self._section_by_id: section = Section(id=section_id, kind="wavefunction.gto") self._section_by_id[section_id] = section self._manifest.sections.append(section)
[docs] def clear_wavefunction_overlays(self, section_ids: list[str] | None = None) -> None: """Drop selected (or all) viewer-computed wavefunctions and their sections.""" for section_id in list(self._wavefunction_overlays) if section_ids is None else section_ids: if section_id not in self._wavefunction_overlays: continue section = self._section_by_id.pop(section_id, None) if section is not None and section in self._manifest.sections: self._manifest.sections.remove(section) self._wavefunction_overlays.pop(section_id)
@property def wavefunction_overlay_ids(self) -> list[str]: """Section ids currently backed by a viewer-computed wavefunction.""" return list(self._wavefunction_overlays) def _read_structure_payload(self, section_id: str) -> dict[str, Any]: """Validate file geometry before constructing atoms or resolving indices. The member's bytes are read and sha256-verified on every call, so a changed archive is always caught. Once verified bytes match a payload already parsed and validated for that digest, the parse and the validation are not repeated: the cached payload is returned, and callers must not mutate it (``read_structure`` builds fresh atoms). """ member = self.get_section(section_id).members["structure"] validation_key = (section_id, member.sha256, self._read_version) cached = self._structure_payloads.get(validation_key) if cached is not None: self._read_member(section_id, "structure", expect_format="json") return cached raw = self._read_json_member(section_id, "structure") if isinstance(raw, dict) and "pbc" not in raw: dim = raw.get("dimensionality") # QVF v0.1.0 §5.1 permits this pre-conformance accommodation. # Explicit pbc remains authoritative; archive bytes are unchanged. if not isinstance(dim, bool) and isinstance(dim, int | float) and dim in (0, 1, 2, 3): raw = {**raw, "pbc": [axis < dim for axis in range(3)]} try: _STRUCTURE_VALIDATORS[self._read_version].validate(raw) assert isinstance(raw, dict) # guaranteed by StructurePayload positions = np.asarray([atom["position"] for atom in raw["atoms"]], dtype=float) if not np.all(np.isfinite(positions)): raise QVFError("atom positions must be finite") numbers = [ _atomic_number(atom["atomic_number"], f"atoms[{i}].atomic_number") for i, atom in enumerate(raw["atoms"]) ] lattice = raw.get("lattice_vectors") if lattice is not None: cell = np.asarray(lattice, dtype=float) if not np.all(np.isfinite(cell)): raise QVFError("lattice_vectors must be finite") pbc = [bool(flag) for flag in raw.get("pbc", [])][:3] if any(pbc): try: np.linalg.inv(cell) except np.linalg.LinAlgError: raise QVFError("periodic lattice_vectors must be invertible") from None # Finite, invertible vectors can still give a zero or # infinite interplanar spacing (1e200, 1e-300, -DBL_MAX), # or atoms too many cells apart to search for bonds; the # bond search then raised ZeroDivisionError (#39-35). try: periodic_image_shells(positions, numbers, cell, pbc) except DegenerateLatticeError as exc: raise QVFError(f"periodic lattice_vectors: {exc}") from None # Geometry the bond search would refuse (a supercell written with # its unit cell, overlapping atoms) is a section error now, not a # process that allocates until it is killed when first drawn # (#62). Bounded work: no distance is computed here. flags = [bool(flag) for flag in raw.get("pbc", [])][:3] try: check_bond_search( positions, numbers, lattice=np.asarray(lattice, dtype=float) if lattice is not None else None, pbc=flags, ) except BondSearchLimitError as exc: raise QVFError(str(exc)) from None except (jsonschema.ValidationError, QVFError, OverflowError) as exc: detail = ( f"{exc.json_path}: {exc.message}" if isinstance(exc, jsonschema.ValidationError) else str(exc) ) error = QVFError(f"structure member in section {section_id!r}: {detail}") self._record_section_error(section_id, error) raise error from None self._structure_payloads[validation_key] = raw return raw def _file_atom_count(self, section_id: str) -> int | None: """Atoms of the geometry ``section_id`` certainly describes, as the file has them. Never the edited or caller-supplied atoms. The bound that rejects an out-of-range index comes from the structure the section's ``structure_ref`` names, or from the archive's only structure. A section without a reference in an archive with several structures makes no statement (spec § 4.1), so its indices are not bounded by a guess, and neither are those of a standalone analysis. """ section = self.get_section(section_id) structure_id = self._named_structure(section) if structure_id is None: structures = self.structure_sections structure_id = structures[0].id if len(structures) == 1 else None if structure_id is None: return None return len(self._read_structure_payload(structure_id)["atoms"]) def _describing(self, kind: str, structure_id: str) -> Section | None: """The first section of ``kind`` that describes ``structure_id``.""" return next( ( s for s in self._manifest.sections if s.kind == kind and self.structure_ref_of(s.id) == structure_id ), None, ) def _read_pair_payload( self, section_id: str, member_name: str, n_atoms: int | None, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: raw = self._read_json_member(section_id, member_name) try: pairs = _parse_ij_pairs(raw, n_atoms) except QVFError as exc: error = QVFError(f"{member_name} member in section {section_id!r}: {exc}") self._record_section_error(section_id, error) raise error from None return raw, pairs
[docs] def read_structure( self, section_id: str | None = None, *, apply_edits: bool = True ) -> StructureData: """Read + verify a structure section. Always eager. ``section_id`` defaults to the shown structure (:attr:`shown_structure_id`, the first structure unless :meth:`show_structure` chose another); an archive without one raises :class:`SectionNotFoundError`. Wavefunctions may reference a different structure, which callers can request explicitly so the displayed atoms, cell, and AO centres stay registered. Set ``apply_edits=False`` to read the archived reference geometry, for example when constructing a displacement from a normal mode. When an edit overlay is set (:meth:`set_edit_overlay`), it applies only to the structure it was made on. Its returned atoms are the edited geometry and ``bonds`` is None (auto-bonding applies). PBC/dimensionality still come from the file; the lattice comes from the overlay when an edit replaced it. The file's biomolecular metadata does not survive an edit: the overlay atoms carry no residue, chain or B-factor fields, and the producer-supplied residues, chains and secondary structure index the replaced atom list, so they are dropped. """ if section_id is None: section_id = self.shown_structure_id if section_id is None: raise SectionNotFoundError("the archive has no structure section") raw = self._read_structure_payload(section_id) pbc, dim = _resolve_pbc(raw) lattice = raw.get("lattice_vectors") if lattice is not None: lattice = np.array(lattice, dtype=np.float64) overlay = self._edit_overlays.get(section_id) if apply_edits else None if overlay is not None: # The overlay replaces the atoms wholesale, so the file's atom # list, bonds and biomolecular metadata are never built for it. edited_atoms, edited_lattice, edited_bonds = overlay return StructureData( atoms=list(edited_atoms), pbc=pbc, lattice_vectors=edited_lattice.copy() if edited_lattice is not None else lattice, bonds=None if edited_bonds is None else list(edited_bonds), dim=dim, # Supplied residue atom_indices index the file's atom list, # which the overlay has replaced, so they are as stale as the # bond indices above. ) atoms = [_atom_from_json(a) for a in raw["atoms"]] # Try to read explicit bonds if present. Early draft archives # embedded a `bonds` member in the structure section; canonical # QVF v1 archives carry a separate `kind: bonds` section. bonds = None structure_section = self.get_section(section_id) # Producer-supplied biomolecule metadata, peer keys on the section # object (QVF spec § 5.1). Captured through Pydantic's extra="allow", # so an archive predating the fields simply has none of them. extra = structure_section.model_extra or {} supplied_residues = extra.get("residues") supplied_chains = extra.get("chains") supplied_ss = extra.get("secondary_structure") # A section-level b_factors array fills in only where the atom did # not carry its own: the per-atom field cannot desynchronize from its # atom, so it wins. A length mismatch means the array does not # describe this atom list, and applying it partially would silently # mislabel every atom past the first divergence, so drop it whole. b_factors = extra.get("b_factors") if isinstance(b_factors, list) and len(b_factors) == len(atoms): for atom, value in zip(atoms, b_factors, strict=True): if atom.b_factor is None: atom.b_factor = _opt_float(value) if "bonds" in structure_section.members: try: bonds = self.read_bonds(section_id, n_atoms=len(atoms)) except QVFError as exc: self._record_section_error(section_id, exc) raise else: # A standalone ``bonds`` section belongs to the structure it # names, or, naming none, to the first structure (spec § 4.1). bond_section = self._describing("bonds", section_id) if bond_section is not None: try: bonds = self.read_bonds(bond_section.id, n_atoms=len(atoms)) except QVFError as e: self._record_section_error(bond_section.id, e) raise QVFError( f"explicit bonds section {bond_section.id!r} could not be read: {e}" ) from None return StructureData( atoms=atoms, pbc=pbc, lattice_vectors=lattice, bonds=bonds, dim=dim, supplied_residues=( supplied_residues if isinstance(supplied_residues, list) else None ), supplied_chains=( [str(c) for c in supplied_chains] if isinstance(supplied_chains, list) else None ), supplied_secondary_structure=( supplied_ss if isinstance(supplied_ss, list) else None ), )
[docs] def read_bonds(self, section_id: str, *, n_atoms: int | None = None) -> list[Bond]: """Read explicit pairs, checking their original structure when available. Omitted orders retain 1.0. Explicit payload images are not specified by QVF v0.1.0 §5.1; retain the existing zero-image convention. """ if n_atoms is None: section = self.get_section(section_id) n_atoms = ( len(self._read_structure_payload(section_id)["atoms"]) if section.kind == "structure" else self._file_atom_count(section_id) ) _, pairs = self._read_pair_payload(section_id, "bonds", n_atoms) return [(p["i"], p["j"], p.get("order", 1.0), (0, 0, 0)) for p in pairs]
[docs] def connectivity( self, structure: StructureData, section_id: str | None = None ) -> list[Bond]: """The bonds every surface draws for ``structure``. The producer's explicit bonds when the structure carries them, otherwise the covalent-radius inference (:func:`vibeview.bonds. structure_bonds`); inferred bonds carry order 1.0, since radii cannot recover multiplicity. When the archive has a ``bond_orders`` section for the structure section ``structure`` was read from (``section_id``, by default the shown structure), its per-pair orders (Mayer/Wiberg, computed from the wavefunction) replace the orders for the pairs it lists. """ bonds = structure_bonds(structure) # Enrich with bond_orders data if available. structure_id = self.shown_structure_id if section_id is None else section_id if structure_id is None: # A caller-supplied structure in an archive without one: the # archive's analysis is the only candidate. bo_section = next( (s for s in self._manifest.sections if s.kind == "bond_orders"), None ) else: bo_section = self._describing("bond_orders", structure_id) if bo_section is not None: bo_data = self.read_bond_orders(bo_section.id) # Build a lookup map: (i, j) → order order_map: dict[tuple[int, int], float] = {} for p in bo_data.pairs: i, j = cast(int, p["i"]), cast(int, p["j"]) order_map[(min(i, j), max(i, j))] = cast(float, p.get("order", 1.0)) # Override bond orders where we have bond_orders data. bonds = [ (i, j, order_map.get((min(i, j), max(i, j)), order), image) for i, j, order, image in bonds ] return bonds
# ── Lazy binary extraction for volumes ──────────────────────────── def _payload_failure(self, section_id: str, member_name: str, error: QVFError) -> QVFError: failure = QVFError(f"{member_name} member in section {section_id!r}: {error}") self._record_section_error(section_id, failure) return failure def _read_grid_payload( self, section_id: str, member_name: str, data_name: str, *, frame_axis: bool = False, ) -> GridData: raw = self._read_json_member(section_id, member_name) member = self.get_section(section_id).members.get(data_name) try: if member is None or member.shape is None: raise QVFError(f"missing binary shape for {data_name!r}") shape = member.shape[1:] if frame_axis else member.shape return _grid_from_json(raw, shape) except QVFError as error: if member is not None: error = QVFError(f"{error}; paired binary member {member.path!r}") raise self._payload_failure(section_id, member_name, error) from None
[docs] def read_volume_grid(self, section_id: str) -> GridData: """Read a finite grid descriptor consistent with its lazy binary member.""" return self._read_grid_payload(section_id, "grid", "data")
[docs] def read_volume_data(self, section_id: str) -> np.ndarray: """Read + verify volume data, including its pairing with the grid.""" grid = self.read_volume_grid(section_id) data = self._read_binary_member(section_id, "data") if data.shape != grid.shape: raise self._payload_failure( section_id, "data", QVFError("cached data shape differs from grid shape"), ) return data
[docs] def read_volume_vector(self, section_id: str) -> VectorFieldData: """Read a ``volume.vector`` section: its grid and ``[nx, ny, nz, 3]`` data (#156). The binary member carries a trailing extent of 3, so the grid's ``shape`` is checked against the leading three dimensions. """ raw = self._read_json_member(section_id, "grid") member = self.get_section(section_id).members.get("data") try: if member is None or member.shape is None or len(member.shape) != 4: raise QVFError("data must be a binary member of shape [nx, ny, nz, 3]") if member.shape[3] != 3: raise QVFError(f"data has {member.shape[3]} components per point, not 3") grid = _grid_from_json(raw, member.shape[:3]) except QVFError as error: raise self._payload_failure(section_id, "grid", error) from None vectors = self._read_binary_member(section_id, "data") if vectors.shape != (*grid.shape, 3): raise self._payload_failure( section_id, "data", QVFError("vector data shape differs from grid shape"), ) units = raw.get("units") if isinstance(raw, dict) else None unit = units.get("data") if isinstance(units, dict) else None return VectorFieldData( grid=grid, vectors=vectors, unit=str(unit) if isinstance(unit, str) else None, )
def _derivative_structure(self, section_id: str, meta: dict[str, Any]) -> str: """The structure a ``forces`` or ``hessian`` section indexes (§ 5.10). The section-level ``structure_ref``, else ``meta.structure_ref``, else the archive's only structure section. """ named = self.named_structure_ref(section_id) if named is not None: return named reference = meta.get("structure_ref") if isinstance(reference, str) and reference in self._section_by_id: if self._section_by_id[reference].kind == "structure": return reference raise QVFError(f"structure_ref {reference!r} is not a structure section") structures = [s.id for s in self.sections if s.kind == "structure"] if len(structures) == 1: return structures[0] raise QVFError("name the structure with structure_ref: the archive has " f"{len(structures)} structure sections") def _derivative_meta(self, section_id: str) -> dict[str, Any]: if "meta" not in self.get_section(section_id).members: return {} return _json_object(self._read_json_member(section_id, "meta"))
[docs] def read_wavefunction_bloch(self, section_id: str) -> BlochWavefunctionData: """Read a ``wavefunction.bloch`` section (QVF spec § 5.3.1, #162).""" basis_raw = self._read_json_member(section_id, "basis") kpoints_raw = self._read_json_member(section_id, "kpoints") meta_raw = self._read_json_member(section_id, "mo_metadata") coefficients = self._read_binary_member(section_id, "mo_coefficients") energies = self._read_binary_member(section_id, "energies") occupations = self._read_binary_member(section_id, "occupations") try: basis = _json_object(basis_raw) meta = _json_object(meta_raw) kp = _json_object(kpoints_raw) global_pure = bool(basis.get("pure", True)) shells = [ _shell_from_json(sh, index, global_pure) for index, sh in enumerate(basis.get("shells", [])) ] structure_id = self.named_structure_ref(section_id) if structure_id is None: reference = basis.get("structure_ref") if isinstance(reference, str) and reference in self._section_by_id: structure_id = reference if structure_id is None or self._section_by_id[structure_id].kind != "structure": raise QVFError("the section names no structure (structure_ref)") structure = self.read_structure(structure_id, apply_edits=False) if not any(structure.pbc) or structure.lattice_vectors is None: raise QVFError(f"structure {structure_id!r} has no periodic axis") kpoints = _numeric_array(kp.get("kpoints"), (len(kp.get("kpoints") or []), 3), "kpoints") n_k = len(kpoints) if n_k < 1: raise QVFError("kpoints needs at least one k-point") n_spin = int(meta.get("n_spin", 0)) n_mo = int(meta.get("n_mo", 0)) n_ao = int(meta.get("n_ao", basis.get("n_ao", 0))) if coefficients.shape != (n_k, n_spin, n_mo, n_ao, 2): raise QVFError(f"mo_coefficients has shape {coefficients.shape}, expected " f"{(n_k, n_spin, n_mo, n_ao, 2)}") for name, array in (("energies", energies), ("occupations", occupations)): expected = (n_k, n_spin, n_mo) if array.shape != expected: raise QVFError(f"{name} has shape {array.shape}, expected {expected}") per_k = meta.get("n_mo_per_k") n_mo_per_k = [int(v) for v in per_k] if isinstance(per_k, list) else [n_mo] * n_k if len(n_mo_per_k) != n_k or any(not 0 <= v <= n_mo for v in n_mo_per_k): raise QVFError("n_mo_per_k needs one count per k-point, each at most n_mo") def optional_list(key: str, length: int) -> list[Any] | None: value = kp.get(key) if value is None: return None if not isinstance(value, list) or len(value) != length: raise QVFError(f"kpoints {key} needs {length} entries") return value weights = optional_list("weights", n_k) labels = optional_list("labels", n_k) mesh = optional_list("mesh", 3) fermi = meta.get("fermi_energy") fermi_eh = None if isinstance(fermi, dict) and isinstance(fermi.get("value"), int | float): value = float(fermi["value"]) fermi_eh = value / HARTREE_TO_EV if fermi.get("units") == "ev" else value except QVFError as error: raise self._payload_failure(section_id, "mo_metadata", error) from None return BlochWavefunctionData( structure_id=structure_id, pure=global_pure, n_ao=n_ao, shells=shells, kpoints=kpoints, spin=str(meta.get("spin", "restricted")), coefficients=coefficients[..., 0] + 1j * coefficients[..., 1], energies=np.asarray(energies, dtype=float), occupations=np.asarray(occupations, dtype=float), n_mo_per_k=n_mo_per_k, weights=None if weights is None else [float(w) for w in weights], labels=None if labels is None else [str(x) for x in labels], mesh=None if mesh is None else [int(v) for v in mesh], time_reversal=kp.get("time_reversal") is True, orbital_kind=str(meta.get("orbital_kind", "canonical")), fermi_energy_eh=fermi_eh, )
[docs] def read_properties(self, section_id: str) -> PropertiesData: """Read a ``properties.*`` section and check the shapes the viewer uses (#161).""" section = self.get_section(section_id) raw = self._read_json_member(section_id, "properties") named = self.named_structure_ref(section_id) structures = [s.id for s in self.sections if s.kind == "structure"] structure_id = named or (structures[0] if len(structures) == 1 else None) n_atoms = ( len(self.read_structure(structure_id, apply_edits=False).atoms) if structure_id is not None else None ) try: payload = _json_object(raw) _check_property_shapes(section.kind, payload, n_atoms) except QVFError as error: raise self._payload_failure(section_id, "properties", error) from None return PropertiesData(kind=section.kind, payload=payload, structure_id=structure_id)
[docs] def read_forces(self, section_id: str) -> ForcesData: """Read a ``forces`` section: the energy gradient and optional stress (#159).""" members = self.get_section(section_id).members try: meta = self._derivative_meta(section_id) structure_id = self._derivative_structure(section_id, meta) except QVFError as error: raise self._payload_failure(section_id, "meta", error) from None gradient = self._read_binary_member(section_id, "gradient") stress = self._read_binary_member(section_id, "stress") if "stress" in members else None n_atoms = len(self.read_structure(structure_id, apply_edits=False).atoms) if gradient.shape != (n_atoms, 3): raise self._payload_failure(section_id, "gradient", QVFError( f"gradient has shape {gradient.shape}, expected ({n_atoms}, 3) " f"for the atoms of {structure_id!r}" )) if stress is not None and stress.shape != (3, 3): raise self._payload_failure(section_id, "stress", QVFError( f"stress has shape {stress.shape}, expected (3, 3)" )) method = meta.get("method") return ForcesData( gradient=np.asarray(gradient, dtype=float), structure_id=structure_id, stress=None if stress is None else np.asarray(stress, dtype=float), method=method if isinstance(method, str) else None, )
[docs] def read_hessian(self, section_id: str) -> HessianData: """Read a ``hessian`` section: the Cartesian second derivatives (#159).""" try: meta = self._derivative_meta(section_id) structure_id = self._derivative_structure(section_id, meta) except QVFError as error: raise self._payload_failure(section_id, "meta", error) from None matrix = self._read_binary_member(section_id, "matrix") n_atoms = len(self.read_structure(structure_id, apply_edits=False).atoms) if matrix.shape != (3 * n_atoms, 3 * n_atoms): raise self._payload_failure(section_id, "matrix", QVFError( f"matrix has shape {matrix.shape}, expected ({3 * n_atoms}, {3 * n_atoms}) " f"for the atoms of {structure_id!r}" )) masses = meta.get("atom_masses_amu") try: if masses is not None: if not isinstance(masses, list) or len(masses) != n_atoms: raise QVFError(f"atom_masses_amu needs {n_atoms} entries") masses = [_json_finite_number(m, f"atom_masses_amu[{i}]") for i, m in enumerate(masses)] if any(m <= 0 for m in masses): raise QVFError("atom_masses_amu must be positive") if meta.get("mass_weighted") is True and masses is None: raise QVFError("a mass-weighted Hessian needs atom_masses_amu") except QVFError as error: raise self._payload_failure(section_id, "meta", error) from None return HessianData( matrix=np.asarray(matrix, dtype=float), structure_id=structure_id, mass_weighted=meta.get("mass_weighted") is True, projected=meta.get("projected") is True, atom_masses_amu=masses, )
[docs] def read_volume_series(self, section_id: str) -> VolumeSeriesData: """Read a ``volume.series`` section: its grid, slabs and axis (#158). ``data`` is ``[n, nx, ny, nz]``; the grid describes one slab. The axis needs one value per slab, and one label per slab when it has labels (QVF spec § 5.2). """ grid = self._read_grid_payload(section_id, "grid", "data", frame_axis=True) raw_axis = self._read_json_member(section_id, "axis") data = self._read_binary_member(section_id, "data") try: if data.ndim != 4 or tuple(data.shape[1:]) != tuple(grid.shape): raise QVFError(f"data has shape {data.shape}, not [n, *{list(grid.shape)}]") axis = _series_axis_from_json(_json_object(raw_axis), int(data.shape[0])) except QVFError as error: raise self._payload_failure(section_id, "axis", error) from None return VolumeSeriesData(grid=grid, data=data, axis=axis)
[docs] def read_volume_complex(self, section_id: str) -> ComplexFieldData: """Read a ``volume.complex`` section: its grid and complex field (#157). The binary member is ``[nx, ny, nz, 2]``, the ``[re, im]`` layout of QVF spec § 5.2; the grid's ``shape`` is checked against the leading three dimensions. """ raw = self._read_json_member(section_id, "grid") member = self.get_section(section_id).members.get("data") try: if member is None or member.shape is None or len(member.shape) != 4: raise QVFError("data must be a binary member of shape [nx, ny, nz, 2]") if member.shape[3] != 2: raise QVFError(f"data has {member.shape[3]} values per point, not [re, im]") grid = _grid_from_json(raw, member.shape[:3]) except QVFError as error: raise self._payload_failure(section_id, "grid", error) from None pairs = self._read_binary_member(section_id, "data") if pairs.shape != (*grid.shape, 2): raise self._payload_failure( section_id, "data", QVFError("complex data shape differs from grid shape"), ) values = pairs[..., 0].astype(np.float64) + 1j * pairs[..., 1].astype(np.float64) units = raw.get("units") if isinstance(raw, dict) else None unit = units.get("data") if isinstance(units, dict) else None return ComplexFieldData( grid=grid, values=values, unit=str(unit) if isinstance(unit, str) else None, )
# ── Other section readers ─────────────────────────────────────────
[docs] def read_bands(self, section_id: str) -> BandsData: kpath = self._read_json_member(section_id, "kpath") eigenvalues = self._read_binary_member(section_id, "eigenvalues") if eigenvalues.ndim == 2: eigenvalues = eigenvalues[np.newaxis, :, :] elif eigenvalues.ndim != 3: raise QVFError( f"bands section {section_id!r}: eigenvalues must have rank 3 " f"[n_spin, n_kpoints, n_bands], got shape {eigenvalues.shape}" ) n_kpoints = kpath.get("n_kpoints") if n_kpoints is not None and int(n_kpoints) != eigenvalues.shape[1]: raise QVFError( f"bands section {section_id!r}: kpath n_kpoints={n_kpoints} " f"does not match eigenvalues shape {eigenvalues.shape}" ) n_bands = kpath.get("n_bands") if n_bands is not None and int(n_bands) != eigenvalues.shape[2]: raise QVFError( f"bands section {section_id!r}: kpath n_bands={n_bands} " f"does not match eigenvalues shape {eigenvalues.shape}" ) fermi = kpath.get("fermi") if fermi is not None and ( isinstance(fermi, bool) or not isinstance(fermi, int | float) or not math.isfinite(float(fermi)) ): raise QVFError( f"bands section {section_id!r}: kpath fermi must be a finite number, " f"got {fermi!r}" ) # A non-finite eigenvalue cannot be drawn, and one near the limit of a # double overflows every axis range: +-DBL_MAX made matplotlib raise # on the E - E_F span (#63). Sixteen times the span must still be # finite, which leaves room for the margins a plot adds. with np.errstate(over="ignore", invalid="ignore"): shifted = np.asarray(eigenvalues, dtype=float) - float(fermi or 0.0) span_ok = shifted.size == 0 or bool(np.isfinite(np.ptp(shifted) * 16.0)) if not np.all(np.isfinite(shifted)) or not span_ok: error = QVFError( f"bands section {section_id!r}: eigenvalues must be finite and " "span a range a plot can draw (well inside double precision)" ) self._record_section_error(section_id, error) raise error return BandsData(kpath=kpath, eigenvalues=eigenvalues, fermi=fermi)
[docs] def read_phonon_bands(self, section_id: str) -> PhononBandsData: """Read a phonon_bands section (QVF spec §4.13). ``qpath`` mirrors the electronic kpath (labeled segments with ``n_points``); ``frequencies`` is ``[n_qpoints, n_modes]`` in cm^-1. """ qpath = self._read_json_member(section_id, "qpath") frequencies = self._read_binary_member(section_id, "frequencies") if frequencies.ndim != 2: raise QVFError( f"phonon_bands section {section_id!r}: frequencies must have rank 2 " f"[n_qpoints, n_modes], got shape {frequencies.shape}" ) return PhononBandsData(qpath=qpath, frequencies=frequencies)
[docs] def read_phonon_dos(self, section_id: str) -> PhononDOSData: """Read a phonon_dos section (QVF spec §4.13). ``frequencies`` and ``dos`` are ``[n_points]``; ``meta`` is required producer metadata (smearing, n_atoms, …), possibly an empty object. """ frequencies = self._read_binary_member(section_id, "frequencies") dos = self._read_binary_member(section_id, "dos") meta = self._read_json_member(section_id, "meta") return PhononDOSData(frequencies=frequencies, dos=dos, meta=meta)
[docs] def read_equation_of_state(self, section_id: str) -> EquationOfStateData: """Read an equation_of_state section (QVF spec §4.14). ``volumes`` / ``energies`` are the sampled V-E points (Angstrom^3 / eV); ``fit`` carries the producer's fitted EOS params (``model``, ``V0``, ``E0``, ``B0`` in GPa, ``B0_prime``, optional unit strings). """ volumes = self._read_binary_member(section_id, "volumes") energies = self._read_binary_member(section_id, "energies") fit = self._read_json_member(section_id, "fit") return EquationOfStateData(volumes=volumes, energies=energies, fit=fit)
[docs] def read_spectra(self, section_id: str) -> SpectraData: """Read a ``spectra.*`` section. ``frequencies`` and ``intensities`` must be flat lists of finite numbers with one shared length; anything else is a :class:`QVFError` recorded against the section (#66). """ raw = self._read_json_member(section_id, "spectrum") try: return _spectra_from_json(_json_object(raw)) except QVFError as error: raise self._payload_failure(section_id, "spectrum", error) from None except (KeyError, TypeError, ValueError) as error: raise self._payload_failure( section_id, "spectrum", QVFError(f"malformed payload ({type(error).__name__}: {error})"), ) from None
def _read_frame_payload( self, section_id: str, ) -> tuple[dict[str, Any], list[Atom], np.ndarray, list[float] | None]: raw = self._read_json_member(section_id, "metadata") coords = self._read_binary_member(section_id, "coords") try: meta = _json_object(raw) atoms = _frame_atoms_from_json(meta) energies = _frame_energies(meta) except QVFError as error: raise self._payload_failure(section_id, "metadata", error) from None member = self.get_section(section_id).members["coords"] key = (section_id, member.sha256) try: if coords.ndim != 3 or coords.shape[1:] != (len(atoms), 3): raise QVFError( f"coords shape {coords.shape} must be (n_frames, {len(atoms)}, 3)" ) if coords.dtype != np.dtype(member.dtype or "float64"): raise QVFError("cached coords dtype differs from its declared binary dtype") if coords.shape != tuple(member.shape or []): raise QVFError("cached coords shape differs from its declared binary shape") immutable = _has_immutable_bytes(coords) if not immutable or self._validated_frame_arrays.get(key) is not coords: if not np.all(np.isfinite(coords)): raise QVFError("coords must contain only finite positions") if immutable: self._validated_frame_arrays[key] = coords except QVFError as error: raise self._payload_failure(section_id, "coords", error) from None return meta, atoms, coords, energies
[docs] def read_trajectory(self, section_id: str) -> TrajectoryData: _, atoms, coords, energies = self._read_frame_payload(section_id) return TrajectoryData(atoms=atoms, coords=coords, energies=energies)
[docs] def read_vibrations(self, section_id: str) -> VibrationsData: meta = self._read_json_member(section_id, "metadata") displacements = self._read_binary_member(section_id, "displacements") try: meta = _json_object(meta) if "atoms" in meta: atoms = [_atom_from_json(a) for a in meta["atoms"]] else: # QVF § 5.5 makes the embedded reference geometry optional. # Modes belong to the referenced (or first) archived structure, # independent of the currently shown structure and its edits. reference = self.structure_ref_of(section_id) if reference is None: raise QVFError("vibrations without atoms need a reference structure") atoms = self.read_structure(reference, apply_edits=False).atoms if "symbols" in meta and meta["symbols"] != [a.symbol for a in atoms]: raise QVFError("vibration symbols do not match the reference structure") frequencies = _mo_vector(meta["frequencies"], "frequencies") per_mode = _vibration_per_mode(meta, len(frequencies)) except (QVFError, KeyError, TypeError, ValueError) as error: raise self._payload_failure( section_id, "metadata", QVFError(str(error)) ) from None # A bare number, or displacements of another shape, used to pass the # reader and then fail in ``table --kind vibrations`` and ``animate`` # (#39-35). All 512 local vibrations sections have this shape. expected = (len(frequencies), len(atoms), 3) if displacements.shape != expected: raise QVFError( f"vibrations section {section_id!r}: displacements have shape " f"{displacements.shape}, expected (n_modes, n_atoms, 3) = {expected}" ) return VibrationsData( atoms=atoms, frequencies=frequencies, displacements=displacements, **per_mode )
[docs] def read_wavefunction_gto(self, section_id: str) -> WavefunctionGTOData: """Read a `wavefunction.gto` section (basis + MO coefficients). A malformed ``basis`` or ``mo_metadata`` payload (a missing shell key, a non-object, a non-numeric value) raises :class:`QVFError` and is recorded against the section, never a bare ``KeyError`` (#35-3). """ overlay = self._wavefunction_overlays.get(section_id) if overlay is not None: return overlay basis_raw = self._read_json_member(section_id, "basis") meta_raw = self._read_json_member(section_id, "mo_metadata") members = self.get_section(section_id).members # Member reads record their own failures (sha256, missing, shape), so # they happen before the payload parsing that is wrapped below. binaries: dict[str, np.ndarray] = { name: self._read_binary_member(section_id, name) for name in ("mo_coefficients", "mo_coefficients_alpha", "mo_coefficients_beta") if name in members } try: data = _wavefunction_from_json( _json_object(basis_raw), _json_object(meta_raw), binaries ) except QVFError as error: raise self._payload_failure(section_id, "basis/mo_metadata", error) from None except (KeyError, TypeError, ValueError, AttributeError) as error: raise self._payload_failure( section_id, "basis/mo_metadata", QVFError(f"malformed payload ({type(error).__name__}: {error})"), ) from None # The geometry the shell centres index (QVF spec § 4.1): the section's # ``structure_ref`` takes precedence over the basis's, and a # wavefunction that names no structure describes the first one. own = self._named_structure(self.get_section(section_id)) if own is not None: data.structure_ref = own elif not self._is_structure(data.structure_ref): data.structure_ref = self.first_structure_id or data.structure_ref nto = data.transition_orbitals if nto is not None and nto.states_ref is not None: # The canonical carrier wins over a deprecated excitation_energy_ev. index = meta_raw.get("state_index") if isinstance(meta_raw, dict) else None if isinstance(index, int) and not isinstance(index, bool) and index >= 1: energy = self._excitation_energy_ev(nto.states_ref, index) if energy is not None: nto.excitation_energy_ev = energy return data
def _excitation_energy_ev(self, states_ref: str, index: int) -> float | None: """The ``excitation_energy`` in eV of state ``index`` of an ``excited_states`` section (spec § 5.12), or None when the section, the state or a known unit is missing. Used only to label orbitals, so it never raises.""" section = self._section_by_id.get(states_ref) if section is None or section.kind != "excited_states" or "states" not in section.members: return None try: payload = self._read_json_member(states_ref, "states") except QVFError: return None states = payload.get("states") if isinstance(payload, dict) else None for state in states if isinstance(states, list) else (): if not isinstance(state, dict) or state.get("index") != index: continue energy = state.get("excitation_energy") if not isinstance(energy, dict): return None value = energy.get("value") if isinstance(value, bool) or not isinstance(value, int | float): return None factor = {"ev": 1.0, "hartree": HARTREE_TO_EV}.get(str(energy.get("units"))) if factor is None or not math.isfinite(value): return None return float(value) * factor return None
[docs] def read_excited_states(self, section_id: str) -> ExcitedStatesData: """Read an ``excited_states`` section (QVF spec § 5.12). The states come back in file order with their energies converted (excitation to eV, total to hartree). A state without its ``index`` or ``excitation_energy``, a repeated index, or a present field of the wrong shape is a malformed payload: the table never shows a guessed value (#109). """ section = self.get_section(section_id) if section.kind != "excited_states": raise QVFError(f"section {section_id!r} is {section.kind!r}, not excited_states") raw = self._read_json_member(section_id, "states") try: payload = _json_object(raw) states_raw = payload.get("states") if not isinstance(states_raw, list): raise QVFError("states must be a list") states = [_excited_state(item) for item in states_raw] indices = [state.index for state in states] if len(set(indices)) != len(indices): raise QVFError("state indices must be unique") meta_raw = payload.get("meta") meta = {} if meta_raw is None else _json_object(meta_raw) except QVFError as error: raise self._payload_failure(section_id, "states", error) from None except (KeyError, TypeError, ValueError, AttributeError) as error: raise self._payload_failure( section_id, "states", QVFError(f"malformed payload ({type(error).__name__}: {error})"), ) from None extra = section.model_extra or {} wavefunction_ref = extra.get("wavefunction_ref") return ExcitedStatesData( states=states, meta=meta, wavefunction_ref=wavefunction_ref if isinstance(wavefunction_ref, str) else None, structure_ref=self._named_structure(section), )
[docs] def read_reaction_path(self, section_id: str) -> ReactionPathData: """Read a `reaction.path` section (self-contained: frames + waypoints). v2 archives may carry a `lattice` binary member + a `dim` integer in the metadata JSON; both surface on the returned ``ReactionPathData`` (None on v1 / molecular). """ meta, atoms, coords, energies = self._read_frame_payload(section_id) try: waypoints = _waypoints_from_json(meta, coords.shape[0]) except QVFError as error: raise self._payload_failure(section_id, "metadata", error) from None rxn_coord = meta.get("reaction_coordinate") # Periodic fields. The lattice binary member is optional: absent for # molecular paths. Its presence, not qvf_version, makes a path # periodic (QVF spec § 3.1). section = self.get_section(section_id) lattice = None if "lattice" in section.members: lattice = self._read_binary_member(section_id, "lattice") dim_field = meta.get("dim") dim_per_frame = meta.get("dim_per_frame") # Optional per-frame volumes (W1). The grid descriptor is tiny # (read eagerly); the 4D blob is decimation-bounded so we read # it here too. Absent on archives without volumes. frame_volumes = None volume_grid = None if "frame_volumes" in section.members: frame_volumes = self._read_binary_member(section_id, "frame_volumes") volume_grid = self._read_grid_payload( section_id, "volume_grid", "frame_volumes", frame_axis=True, ) vol_frame_index = meta.get("volume_frame_index") # The scalar metadata is converted here; a wrong type is a malformed # payload, reported as QVFError and recorded, not a bare ValueError # (#35-3). try: return self._reaction_path_data( meta, atoms, coords, energies, waypoints, rxn_coord, lattice, dim_field, dim_per_frame, frame_volumes, volume_grid, vol_frame_index, ) except (KeyError, TypeError, ValueError) as error: raise self._payload_failure( section_id, "metadata", QVFError(f"malformed payload ({type(error).__name__}: {error})"), ) from None
@staticmethod def _reaction_path_data( meta: dict[str, Any], atoms: list[Atom], coords: np.ndarray, energies: list[float] | None, waypoints: list[ReactionWaypoint], rxn_coord: Any, lattice: np.ndarray | None, dim_field: Any, dim_per_frame: Any, frame_volumes: np.ndarray | None, volume_grid: GridData | None, vol_frame_index: Any, ) -> ReactionPathData: return ReactionPathData( atoms=atoms, coords=coords, energies=energies, reaction_coordinate=list(rxn_coord) if rxn_coord is not None else None, waypoints=waypoints, lattice=lattice, dim=int(dim_field) if dim_field is not None else None, dim_per_frame=[int(d) for d in dim_per_frame] if dim_per_frame else None, reaction_coordinate_label=_str_or_none(meta.get("reaction_coordinate_label")), reaction_coordinate_unit=_str_or_none(meta.get("reaction_coordinate_unit")), frame_volumes=frame_volumes, volume_grid=volume_grid, volume_frame_index=( [int(i) for i in vol_frame_index] if vol_frame_index is not None else None ), volume_label=_str_or_none(meta.get("volume_label")), volume_isovalue=( float(meta["volume_isovalue"]) if meta.get("volume_isovalue") is not None else None ), )
[docs] def read_reaction_waypoints(self, section_id: str) -> ReactionWaypointsData: """Read a `reaction.waypoints` annotation that points at a trajectory.""" section = self.get_section(section_id) traj_ref = section.trajectory_ref if not traj_ref: raise self._payload_failure( section_id, "waypoints", QVFError("missing trajectory_ref"), ) raw = self._read_json_member(section_id, "waypoints") try: payload = _json_object(raw) target = self.get_section(str(traj_ref)) if target.kind != "trajectory": raise QVFError(f"trajectory_ref {traj_ref!r} must reference a trajectory") coords_member = target.members.get("coords") if coords_member is None or coords_member.shape is None: raise QVFError(f"trajectory_ref {traj_ref!r} has no coords shape") waypoints = _waypoints_from_json(payload, coords_member.shape[0]) except QVFError as error: raise self._payload_failure(section_id, "waypoints", error) from None rxn_coord = payload.get("reaction_coordinate") return ReactionWaypointsData( trajectory_ref=str(traj_ref), waypoints=waypoints, reaction_coordinate=list(rxn_coord) if rxn_coord is not None else None, )
[docs] def read_scan_surface(self, section_id: str) -> ScanSurfaceData: """Read a `scan.surface` section (2D relaxed energy grid).""" meta = self._read_json_member(section_id, "metadata") axis_a = self._read_binary_member(section_id, "axis_a") axis_b = self._read_binary_member(section_id, "axis_b") energies = self._read_binary_member(section_id, "energies") section = self.get_section(section_id) atoms = None geometries = None if "geometries" in section.members: geometries = self._read_binary_member(section_id, "geometries") # Scan geometries live in the binary member; the metadata atoms # carry identities only. atoms = [_atom_from_json(a, position=np.zeros(3)) for a in meta.get("atoms", [])] def _opt(key: str) -> str | None: return _str_or_none(meta.get(key)) return ScanSurfaceData( axis_a=axis_a, axis_b=axis_b, energies=energies, coordinate_a_label=_opt("coordinate_a_label"), coordinate_a_unit=_opt("coordinate_a_unit"), coordinate_b_label=_opt("coordinate_b_label"), coordinate_b_unit=_opt("coordinate_b_unit"), atoms=atoms, geometries=geometries, )
[docs] def read_atom_properties(self, section_id: str) -> AtomPropertiesData: """Read population-analysis charges and spin populations.""" mulliken = None loewdin = None hirshfeld = None spin_pop = None section = self.get_section(section_id) if "mulliken_charge" in section.members: mulliken = self._read_binary_member(section_id, "mulliken_charge") if "loewdin_charge" in section.members: loewdin = self._read_binary_member(section_id, "loewdin_charge") if "hirshfeld_charge" in section.members: hirshfeld = self._read_binary_member(section_id, "hirshfeld_charge") if "spin_population" in section.members: spin_pop = self._read_binary_member(section_id, "spin_population") iao = None if "iao_charge" in section.members: iao = self._read_binary_member(section_id, "iao_charge") # Every array is one value per atom, so they must agree. Arrays of # different lengths made the atom_properties table index past the # shorter one (#63). lengths = { name: arr.shape for name, arr in ( ("mulliken_charge", mulliken), ("loewdin_charge", loewdin), ("hirshfeld_charge", hirshfeld), ("spin_population", spin_pop), ("iao_charge", iao), ) if arr is not None } if any(len(shape) != 1 for shape in lengths.values()) or len( {shape[0] for shape in lengths.values()} ) > 1: detail = ", ".join(f"{name} {list(shape)}" for name, shape in lengths.items()) error = QVFError( f"atom_properties section {section_id!r}: every array must be one " f"value per atom, with one shared length; got {detail}" ) self._record_section_error(section_id, error) raise error return AtomPropertiesData( mulliken_charges=mulliken, loewdin_charges=loewdin, hirshfeld_charges=hirshfeld, spin_populations=spin_pop, iao_charges=iao, )
[docs] def read_nmr(self, section_id: str) -> NMRData: """Read the NMR spectrum payload from a spectra.nmr section. The producer hands the writer an opaque dict with conventional keys (chemical_shifts, shielding_tensors, j_couplings, isotope, reference, solvent); we surface the dict as-is. """ raw = self._read_json_member(section_id, "spectrum") if not isinstance(raw, dict): raise QVFError( f"spectra.nmr section {section_id!r}: 'spectrum' member is not a JSON object" ) return NMRData(raw=raw)
[docs] def read_epr(self, section_id: str) -> EPRData: """Read the EPR parameter payload from a spectra.epr section. The producer hands the writer an opaque dict with conventional keys (g_tensor, hyperfine, zero_field_splitting); we surface it as-is. """ raw = self._read_json_member(section_id, "spectrum") if not isinstance(raw, dict): raise QVFError( f"spectra.epr section {section_id!r}: 'spectrum' member is not a JSON object" ) return EPRData(raw=raw)
[docs] def read_symmetry(self, section_id: str) -> SymmetryData: """Read the spglib-style symmetry summary from a structure.symmetry section.""" raw = self._read_json_member(section_id, "data") if not isinstance(raw, dict): raise QVFError( f"structure.symmetry section {section_id!r}: 'data' member is not a JSON object" ) return SymmetryData(raw=raw)
[docs] def read_scf_history(self, section_id: str) -> SCFHistoryData: """Read the per-iteration SCF history from a scf_history section. Iteration record keys are conventional (see :class:`SCFHistoryData`); the renderer tolerates missing keys. """ raw = self._read_json_member(section_id, "iterations") iters = raw.get("iterations") if isinstance(raw, dict) else None if not isinstance(iters, list): raise QVFError( f"scf_history section {section_id!r}: missing or malformed 'iterations' list" ) return SCFHistoryData(iterations=iters)
[docs] def read_citations(self, section_id: str) -> CitationsData: """Read the BibTeX bytes from a citations section. The writer stores them as a binary member (utf-8) per design § 1.4 / qvf.py::_write_citations_section. We decode to str here so renderers don't need to know the on-disk encoding. """ return CitationsData(bibtex=self._read_text_member(section_id, "references"))
[docs] def read_run_record(self, section_id: str) -> RunRecordData: """Read a run.record section — verbatim input + full log of one program invocation (spec § 5.8). Text members are decoded UTF-8 with ``errors="replace"`` (the format requires UTF-8; a lenient decode keeps a slightly broken producer viewable). Attachment members are listed but not read — they can be arbitrary bytes and are only surfaced by name. """ section = self.get_section(section_id) extras = getattr(section, "model_extra", None) or {} def _text_and_size(role: str) -> tuple[str | None, int]: member = section.members.get(role) if member is None: return None, 0 size = self.stored_size(member) or 0 return self._read_text_member(section_id, role, errors="replace"), size input_text, input_size = _text_and_size("input") log_text, log_size = _text_and_size("log") files: dict[str, Any] = {} if "files" in section.members: raw_files = self._read_json_member(section_id, "files") if isinstance(raw_files, dict): files = raw_files attachment_roles = sorted( role for role in section.members if role.startswith("attachment.") ) exit_status = extras.get("exit_status") sequence = extras.get("sequence") return RunRecordData( program=str(extras.get("program", "unknown")), program_version=extras.get("program_version"), command=extras.get("command"), exit_status=int(exit_status) if exit_status is not None else None, started_utc=extras.get("started_utc"), finished_utc=extras.get("finished_utc"), sequence=int(sequence) if sequence is not None else None, input_text=input_text, input_size=input_size, log_text=log_text, log_size=log_size, files=files, attachment_roles=attachment_roles, )
[docs] def read_run_record_attachment( self, section_id: str, role: str, *, max_bytes: int = 4 * 1024 * 1024 ) -> str | None: """Decode a run.record ``attachment.*`` member as UTF-8 text. Attachments are arbitrary bytes; this returns None (rather than raising) when the member is absent, larger than ``max_bytes`` (uncompressed, checked from the ZIP central directory before reading), or not valid UTF-8 — the renderer then falls back to listing the attachment by name. """ section = self.get_section(section_id) member = section.members.get(role) if member is None: return None size = self.stored_size(member) if size is None: return None if size > max_bytes: return None raw = self._read_member(section_id, role) try: return raw.decode("utf-8") except UnicodeDecodeError: return None
#: Hard ceiling on a single attachment download. The bytes are #: base64-encoded into a state value and pushed over the websocket, #: so this bounds memory and socket pressure, not disk. ATTACHMENT_DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024
[docs] def run_record_attachments(self, section_id: str) -> list[dict[str, Any]]: """Describe a run.record's ``attachment.*`` members for listing. Metadata only — no payload is read. Each entry carries ``role``, a ``filename`` (sanitised to a bare basename; the files index is producer-supplied and must never steer a write), ``size`` in bytes, and ``too_large`` against :attr:`ATTACHMENT_DOWNLOAD_MAX_BYTES`. """ section = self.get_section(section_id) files: dict[str, Any] = {} if "files" in section.members: raw = self._read_json_member(section_id, "files") if isinstance(raw, dict): files = raw out: list[dict[str, Any]] = [] for role in sorted(section.members): if not role.startswith("attachment."): continue member = section.members[role] size = self.stored_size(member) if size is None: continue raw_entry = files.get(role) entry: dict[str, Any] = raw_entry if isinstance(raw_entry, dict) else {} out.append( { "role": role, "filename": _safe_basename( entry.get("filename"), role.split(".", 1)[-1] ), "description": str(entry.get("description") or ""), "size": size, "too_large": size > self.ATTACHMENT_DOWNLOAD_MAX_BYTES, } ) return out
[docs] def read_run_record_attachment_bytes( self, section_id: str, role: str ) -> bytes | None: """Raw bytes of one ``attachment.*`` member, for download only. Deliberately separate from :meth:`read_run_record_attachment`, which decodes text for rendering: attachments are arbitrary bytes by spec and callers must not be tempted to put these in the DOM. Returns None when the member is absent or exceeds :attr:`ATTACHMENT_DOWNLOAD_MAX_BYTES` (checked from the ZIP central directory *before* reading, so a zip bomb cannot be expanded into memory first). """ if not role.startswith("attachment."): return None section = self.get_section(section_id) member = section.members.get(role) if member is None: return None size = self.stored_size(member) if size is None: return None if size > self.ATTACHMENT_DOWNLOAD_MAX_BYTES: return None return self._read_member(section_id, role)
[docs] def read_job_spec(self, section_id: str) -> JobSpecData: """Read a job.spec section — the declarative request the archive carries (spec § 5.9). Pair with :attr:`run_status` to tell a pending container from a settled one.""" raw = self._read_json_member(section_id, "spec") if not isinstance(raw, dict): raise QVFError( f"job.spec section {section_id!r}: 'spec' member is not " f"a JSON object" ) charge = raw.get("charge") multiplicity = raw.get("multiplicity") kpoints = raw.get("kpoints") return JobSpecData( job_type=str(raw.get("job_type", "unknown")), method=raw.get("method"), # null means the method takes none; "" is its deprecated # spelling, which consumers read as null (spec § 5.9, qvf 0.3). basis=raw.get("basis") or None, functional=raw.get("functional") or None, charge=int(charge) if charge is not None else None, multiplicity=( int(multiplicity) if multiplicity is not None else None ), kpoints=list(kpoints) if isinstance(kpoints, list) else None, tasks=[str(t) for t in raw.get("tasks", [])], options=( dict(raw["options"]) if isinstance(raw.get("options"), dict) else {} ), raw=raw, )
[docs] def read_reaction_scheme(self, section_id: str) -> ReactionSchemeData: """Read a ``reaction.scheme`` section (QVF spec § 5.14). The payload relates the archive's ``structure`` sections as the reactants, reagents and products of each step. A participant that names no ``structure`` section, a repeated step id or a step without reactants or products raises :class:`QVFError`, recorded against the section. Vendor ``x_`` keys are ignored. """ raw = self._read_json_member(section_id, "scheme") def fail(detail: str) -> QVFError: return self._payload_failure(section_id, "scheme", QVFError(detail)) def participants(step: dict[str, Any], role: str, where: str) -> list[ReactionParticipant]: entries = step.get(role, []) if not isinstance(entries, list): raise fail(f"{where}.{role} is not an array") out: list[ReactionParticipant] = [] for index, entry in enumerate(entries): ref = entry.get("structure_ref") if isinstance(entry, dict) else None if not isinstance(ref, str) or not self._is_structure(ref): raise fail(f"{where}.{role}[{index}] names no structure section: {ref!r}") coefficient = entry.get("coefficient", 1.0) if ( isinstance(coefficient, bool) or not isinstance(coefficient, int | float) or not coefficient > 0 ): raise fail(f"{where}.{role}[{index}].coefficient must be a positive number") out.append(ReactionParticipant(ref, float(coefficient))) return out steps_raw = raw.get("steps") if isinstance(raw, dict) else None if not isinstance(steps_raw, list) or not steps_raw: raise fail("'steps' must be a non-empty array") steps: list[ReactionStep] = [] seen: set[str] = set() for index, step in enumerate(steps_raw): where = f"steps[{index}]" if not isinstance(step, dict): raise fail(f"{where} is not an object") step_id = step.get("id") if not isinstance(step_id, str) or not step_id: raise fail(f"{where}.id must be a non-empty string") if step_id in seen: raise fail(f"step id {step_id!r} occurs twice") seen.add(step_id) reactants = participants(step, "reactants", where) products = participants(step, "products", where) if not reactants or not products: raise fail(f"{where} needs at least one reactant and one product") arrow = step.get("arrow", "forward") if arrow not in REACTION_ARROWS: raise fail(f"{where}.arrow {arrow!r} is not one of {', '.join(REACTION_ARROWS)}") conditions = step.get("conditions", []) if not isinstance(conditions, list) or not all(isinstance(c, str) for c in conditions): raise fail(f"{where}.conditions must be an array of strings") yield_percent = step.get("yield_percent") if yield_percent is not None and ( isinstance(yield_percent, bool) or not isinstance(yield_percent, int | float) or not 0 <= yield_percent <= 100 ): raise fail(f"{where}.yield_percent must be a number from 0 to 100") label, yield_text = step.get("label"), step.get("yield_text") steps.append( ReactionStep( id=step_id, reactants=reactants, products=products, reagents=participants(step, "reagents", where), label=label if isinstance(label, str) else None, arrow=str(arrow), conditions=list(conditions), yield_percent=None if yield_percent is None else float(yield_percent), yield_text=yield_text if isinstance(yield_text, str) else None, ) ) return ReactionSchemeData(steps=steps)
#: Largest preview member read for display: the spec recommends at most #: 1 MiB, and the bytes travel to the browser as a data URL. PREVIEW_MAX_BYTES = 4 * 1024 * 1024
[docs] def read_preview(self, section_id: str, role: str = "thumbnail") -> PreviewImage | None: """One verified member of a ``preview`` section (QVF spec § 5.15). ``role`` is ``thumbnail`` or ``image``. Returns None when the member is absent, declares no ``media_type``, or exceeds :attr:`PREVIEW_MAX_BYTES` (checked from the ZIP directory before reading). The picture describes the archive and is not data: nothing reads chemistry from it, and it is never drawn in the 3D scene. """ member = self.get_section(section_id).members.get(role) if member is None or not member.media_type: return None size = self.stored_size(member) if size is None or size > self.PREVIEW_MAX_BYTES: return None return PreviewImage(role, member.media_type, self._read_member(section_id, role))
[docs] def read_bond_orders(self, section_id: str) -> BondOrdersData: """Read analysis pairs validated against original file geometry. Edit overlays do not change the atom identities of archived analyses. Standalone analysis sections still validate without an upper bound. """ raw, pairs = self._read_pair_payload( section_id, "bond_orders", self._file_atom_count(section_id) ) return BondOrdersData(method=str(raw.get("method", "unknown")), pairs=pairs)
[docs] def read_topology_qtaim(self, section_id: str) -> QTAIMData: """Read a topology.qtaim section (critical points + bond paths).""" raw = self._read_json_member(section_id, "critical_points") if not isinstance(raw, dict): raise QVFError( f"topology.qtaim section {section_id!r}: " "'critical_points' member is not a JSON object" ) raw_paths = raw.get("bond_paths") return QTAIMData( points=list(raw.get("points", [])), bond_paths=list(raw_paths) if raw_paths else None, )
[docs] def read_dos(self, section_id: str) -> DOSData: """Read a ``dos.total`` or ``dos.projected`` section. A spin-projected ``[2, n_ch, n_pts]`` projection is summed over spin so a consumer sees one series per channel; a total DOS keeps its ``[2, n_pts]`` spin axis for the caller to split. """ section = self.get_section(section_id) energies = self._read_binary_member(section_id, "energies") projected = section.kind == "dos.projected" extra = section.model_extra or {} if projected: values = self._read_binary_member(section_id, "projections") if values.ndim == 3: values = values.sum(axis=0) raw_channels = extra.get("channels") channels = list(raw_channels) if isinstance(raw_channels, list) else [] else: values = self._read_binary_member(section_id, "dos") channels = [] return DOSData(energies=energies, values=values, channels=channels, projected=projected)
[docs] def read_bands_projections(self, section_id: str) -> tuple[np.ndarray | None, list[str]]: """Optional fat-band projections of a ``bands`` section. Returns ``(projections [n_kpoints, n_bands, n_channels], channel labels)``, or ``(None, [])`` when the section carries none. """ section = self.get_section(section_id) if "projections" not in section.members: return None, [] projections = self._read_binary_member(section_id, "projections") kpath = self._read_json_member(section_id, "kpath") raw = kpath.get("channels", []) if isinstance(kpath, dict) else [] return projections, [str(c) for c in raw] if isinstance(raw, list) else []
[docs] def read_fermi_surface(self, section_id: str) -> FermiSurfaceData: """Read a ``fermi_surface`` section: energy mesh plus its metadata.""" raw_mesh = self._read_json_member(section_id, "mesh") try: mesh = _json_object(raw_mesh) except QVFError as error: raise self._payload_failure(section_id, "mesh", error) from None energies = self._read_binary_member(section_id, "energies") return FermiSurfaceData(energies=energies, mesh=mesh)
[docs] def read_json_member(self, section_id: str, member_name: str) -> Any: """The parsed JSON of one member, verified against the manifest. For kinds without a typed reader (vendor sections, ``wannier`` centres); the typed ``read_*`` methods are preferred where one exists. Part of the renderer plugin contract (#113). """ return self._read_json_member(section_id, member_name)
[docs] def read_text_member(self, section_id: str, member_name: str) -> str: """The UTF-8 text of one member, verified against the manifest. For kinds without a typed reader; part of the renderer plugin contract (#113). A member that is not UTF-8 raises ``QVFError``. """ return self._read_text_member(section_id, member_name)
[docs] def read_binary_member(self, section_id: str, member_name: str) -> np.ndarray: """One ``binary`` member as an array, verified against the manifest. The manifest's ``dtype`` (default ``float32``) and ``shape`` apply. For kinds without a typed reader; part of the renderer plugin contract (#113). """ return self._read_binary_member(section_id, member_name)
[docs] def read_dos_coop(self, section_id: str) -> DOSCOOPData: """Read a dos.coop or dos.cohp section.""" energies = self._read_binary_member(section_id, "energies") projections = self._read_binary_member(section_id, "projections") integrated = self._read_binary_member(section_id, "integrated") raw_meta = self._read_json_member(section_id, "meta") try: meta = _json_object(raw_meta) except QVFError as error: # A non-object meta is malformed, not absent: the sibling readers # raise here too, rather than inventing an empty dict (#35-26). raise self._payload_failure(section_id, "meta", error) from None return DOSCOOPData( energies=energies, projections=projections, integrated=integrated, meta=meta, )
[docs] def close(self) -> None: self._zf.close() if self._buffer is not None: self._buffer.close() self._buffer = None
def __enter__(self) -> QVFReader: return self def __exit__(self, *args: object) -> None: self.close()
def _section_read(method: Any) -> Any: """Wrap a section reader so only QVFError leaves it (#39-35). A member can be valid JSON of an unexpected shape, and a per-kind reader that did not anticipate that shape raises whatever it tripped over (TypeError, KeyError, OverflowError, ...). The CLI, the API, the TUI and the app handle QVFError. Anything else is recorded as the section's ``invalid payload`` error and re-raised as QVFError, chained to the original. KeyboardInterrupt and SystemExit pass through. """ @functools.wraps(method) def wrapper(self: QVFReader, *args: Any, **kwargs: Any) -> Any: try: return method(self, *args, **kwargs) except QVFError: raise except Exception as error: section_id = args[0] if args else kwargs.get("section_id") if section_id is None: # read_structure() reads the shown structure section_id = self.shown_structure_id failure = QVFError( f"section {section_id!r} has an invalid payload: {_describe_exception(error)}" ) if isinstance(section_id, str) and section_id in self._section_by_id: self._record_section_error(section_id, failure) raise failure from error return wrapper # Every public section reader: ``read_<kind>`` plus ``run_record_attachments``. # ``read_member_bytes`` takes a zip path, not a section, and already maps # every archive error to QVFError. _SECTION_READERS: tuple[str, ...] = tuple( sorted( name for name in vars(QVFReader) if (name.startswith("read_") and name != "read_member_bytes") or name == "run_record_attachments" ) ) for _name in _SECTION_READERS: setattr(QVFReader, _name, _section_read(vars(QVFReader)[_name])) del _name