"""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")