Source code for vibesketch.document.tables

"""CSV, Markdown and booktabs tables from the validated result report (#30)."""

from __future__ import annotations

import csv
import html
import io
import json
import re
from typing import Any, Literal

from vibesketch.document.model import Document
from vibesketch.document.results import ResultRefused, Selection, reaction_energies

TableFormat = Literal["csv", "markdown", "latex"]
MEDIA_TYPES = {"csv": "text/csv", "markdown": "text/markdown", "latex": "application/x-latex"}
EXTENSIONS = {"csv": "csv", "markdown": "md", "latex": "tex"}


def _terms(row: dict[str, Any], sign: int) -> list[dict[str, Any]]:
    return [
        {**source, "coefficient": abs(source["coefficient"])}
        for source in row["sources"]
        if source["coefficient"] * sign > 0
    ]


def _csv(rows: list[dict[str, Any]]) -> str:
    stream = io.StringIO(newline="")
    writer = csv.writer(stream, lineterminator="\n")
    writer.writerow(
        [
            "reaction",
            "program",
            "specification_json",
            "temperature_K",
            "pressure_atm",
            "reactants_json",
            "products_json",
            "delta_E_hartree",
            "delta_H_hartree",
            "delta_G_hartree",
            "delta_E_kcal_mol",
            "delta_H_kcal_mol",
            "delta_G_kcal_mol",
        ]
    )
    for row in rows:
        # JSON cells retain variable participant counts and complete provenance,
        # without duplicating a reaction into several CSV rows.
        cells: list[Any] = [
            row["reaction"],
            row["program"],
            json.dumps(row["specification"], ensure_ascii=False),
            row["temperature_k"] if row["temperature_k"] is not None else "",
            row["pressure_atm"] if row["pressure_atm"] is not None else "",
            json.dumps(_terms(row, -1), ensure_ascii=False, allow_nan=False),
            json.dumps(_terms(row, 1), ensure_ascii=False, allow_nan=False),
        ]
        for unit in ("value", "kcal_mol"):
            cells.extend(
                row["energies"][key][unit] if key in row["energies"] else ""
                for key in ("energy", "enthalpy", "gibbs")
            )
        # Do not let a user-controlled text cell become a spreadsheet formula.
        writer.writerow(
            [
                "'" + c if isinstance(c, str) and c.lstrip().startswith(("=", "+", "-", "@")) else c
                for c in cells
            ]
        )
    return stream.getvalue()


def _participants(row: dict[str, Any], sign: int) -> str:
    groups = []
    for term in _terms(row, sign):
        result = term["result"]
        lines = [
            f"{term['coefficient']:g} x {term['species']}",
            f"E = {result['energy']['value']:.12f}",
        ]
        thermal = result.get("thermochemistry")
        if thermal:
            lines += [
                f"H = {thermal['enthalpy']['value']:.12f}",
                f"G = {thermal['gibbs']['value']:.12f}",
            ]
        lines.append(f"run: {term['run']}")
        groups.append("\n".join(lines))
    return "\n\n".join(groups)


def _display(row: dict[str, Any]) -> list[str]:
    spec = row["specification"]
    model = [row["program"], f"{spec['method']}/{spec.get('basis', 'composite')}", spec["task"]]
    extras = {k: v for k, v in spec.items() if k not in {"method", "basis", "task", "resources"}}
    if extras:
        model.append(json.dumps(extras, ensure_ascii=False, sort_keys=True))
    if row["temperature_k"] is not None:
        model.append(f"{row['temperature_k']:g} K; {row['pressure_atm']:g} atm")
    return [
        row["reaction"],
        "\n".join(model),
        _participants(row, -1),
        _participants(row, 1),
        *[
            f"{row['energies'][key]['kcal_mol']:.3f}" if key in row["energies"] else "—"
            for key in ("energy", "enthalpy", "gibbs")
        ],
    ]


def _markdown(value: str) -> str:
    value = html.escape(value, quote=False)
    return re.sub(r"([\\`*_\[\]|])", r"\\\1", value).replace("\n", "<br>")


def _latex(value: str) -> str:
    escapes = {
        "\\": r"\textbackslash{}",
        "&": r"\&",
        "%": r"\%",
        "$": r"\$",
        "#": r"\#",
        "_": r"\_",
        "{": r"\{",
        "}": r"\}",
        "~": r"\textasciitilde{}",
        "^": r"\textasciicircum{}",
        "—": "--",
    }
    # Break long identifiers, but keep ordinary words and numbers together.
    pieces = []
    for token in re.split(r"(\s+)", value):
        if token.isspace():
            pieces.append(token.replace("\n", r"\newline{}"))
            continue
        chunks = (
            [token[i : i + 16] for i in range(0, len(token), 16)] if len(token) > 24 else [token]
        )
        pieces.append(
            r"\allowbreak{}".join("".join(escapes.get(c, c) for c in chunk) for chunk in chunks)
        )
    return "".join(pieces)


[docs] def reaction_table(document: Document, selection: Selection, format: TableFormat = "csv") -> bytes: """One row per step, with explicit source terms; never select or parse runs anew. CSV preserves round-trip numeric precision and JSON participant provenance. Presentation formats show absolute energies to 12 decimal places in hartree and deltas to 3 decimal places in kcal/mol. Missing thermal data is not zero. LaTeX is a fragment requiring booktabs (Unicode text needs a Unicode engine). """ if format not in MEDIA_TYPES: raise ResultRefused("table format must be csv, markdown or latex") rows = reaction_energies(document, selection) if format == "csv": return _csv(rows).encode("utf-8") headers = [ "Step", "Method / conditions", "Reactants (absolute Eh)", "Products (absolute Eh)", "Delta E (kcal/mol)", "Delta H (kcal/mol)", "Delta G (kcal/mol)", ] values = [headers, *[_display(row) for row in rows]] note = ( "Absolute energies: 12 decimal places in hartree; deltas: 3 in kcal/mol. " "Dash: unavailable, not zero. CSV retains full numeric precision and source provenance." ) if format == "markdown": lines = ["| " + " | ".join(_markdown(c) for c in line) + " |" for line in values] lines.insert(1, "| " + " | ".join(["---"] * len(headers)) + " |") return ("\n".join(lines) + "\n\n" + note + "\n").encode("utf-8") columns = ( "@{}" + "".join(f"p{{{w}\\linewidth}}" for w in (0.07, 0.14, 0.20, 0.20, 0.095, 0.095, 0.095)) + "@{}" ) lines = [ r"% Requires \usepackage{booktabs}; compile Unicode text with LuaLaTeX or XeLaTeX.", r"\begingroup\footnotesize\setlength{\tabcolsep}{2pt}", r"\renewcommand{\arraystretch}{1.2}", r"\begin{tabular}{" + columns + "}", r"\toprule", ] for index, line in enumerate(values): if index == 0: line = [c.replace(" / ", "\n").replace(" (", "\n(") for c in line] lines.append(" & ".join(r"{\raggedright " + _latex(c) + r"\par}" for c in line) + r" \\") if index == 0: lines.append(r"\midrule") lines += [r"\bottomrule", r"\end{tabular}", r"\par\smallskip " + _latex(note), r"\endgroup", ""] return "\n".join(lines).encode("utf-8")