"""Species property tables and property text under a structure (#93).
The values are the species report's (:mod:`vibesketch.chem.report`): formula,
masses, elemental analysis and calculated descriptors, labelled as estimates.
CSV keeps full precision; Markdown and LaTeX show two decimals, as the
analysis line does.
"""
from __future__ import annotations
import copy
import csv
import io
import json
from collections.abc import Sequence
from typing import Any, Literal
from vibesketch.document.model import Canvas, Document
from vibesketch.document.tables import MEDIA_TYPES, TableFormat, _latex, _markdown
__all__ = [
"FIELDS",
"MEDIA_TYPES",
"PropertiesRefused",
"property_lines",
"property_table",
"with_property_text",
]
Field = Literal["formula", "mass", "analysis", "logp", "tpsa"]
#: What a property text can show, in the order it shows them.
FIELDS: tuple[Field, ...] = ("formula", "mass", "analysis", "logp", "tpsa")
#: Space between a structure, or its compound number, and the text, in bond lengths.
_GAP = 0.5
#: The descriptors that are model estimates; the others are counts.
_ESTIMATES = {"logp", "molar_refractivity", "tpsa"}
[docs]
class PropertiesRefused(ValueError):
"""A table or text that cannot be made as asked."""
def _rows(document: Document) -> list[dict[str, Any]]:
from vibesketch.chem.report import report
rows = [row.to_dict() for row in report(document, names=False)]
if not rows:
raise PropertiesRefused("the document has no species")
return rows
def _two(value: float | None) -> str:
# Adding 0.0 turns a rounded -0.0 into 0.0, so nothing prints as -0.00.
return "" if value is None else f"{round(value, 2) + 0.0:.2f}"
[docs]
def property_table(document: Document, format: TableFormat = "csv") -> bytes:
"""One row per species: formula, masses, elemental analysis and descriptors."""
if format not in MEDIA_TYPES:
raise PropertiesRefused("table format must be csv, markdown or latex")
rows = _rows(document)
if format == "csv":
stream = io.StringIO(newline="")
writer = csv.writer(stream, lineterminator="\n")
names = [
"logp",
"molar_refractivity",
"tpsa",
"hbond_donors",
"hbond_acceptors",
"rotatable_bonds",
"heavy_atoms",
"rings",
]
writer.writerow(
[
"species",
"label",
"smiles",
"formula",
"average_mass",
"monoisotopic_mass",
"analysis",
"mass_percent_json",
*(f"{n}_estimate" if n in _ESTIMATES else n for n in names),
]
)
for row in rows:
analysis, found = row["analysis"] or {}, row["descriptors"] or {}
writer.writerow(
[
row["id"],
row["label"] or "",
row["smiles"] or "",
row["formula"] or "",
"" if row["average_mass"] is None else row["average_mass"],
"" if row["monoisotopic_mass"] is None else row["monoisotopic_mass"],
analysis.get("line", ""),
json.dumps(analysis.get("mass_percent", {}), allow_nan=False),
*(found.get(n, "") for n in names),
]
)
return stream.getvalue().encode("utf-8")
headers = [
"Species",
"Formula",
"M (g/mol)",
"Elemental analysis",
"logP",
"MR",
"TPSA",
"HBD / HBA",
"Rot. bonds",
]
values = [headers]
for row in rows:
found = row["descriptors"] or {}
name = row["id"] + (f" ({row['label']})" if row["label"] else "")
values.append(
[
name,
row["formula"] or "",
_two(row["average_mass"]),
(row["analysis"] or {}).get("line", ""),
_two(found.get("logp")),
_two(found.get("molar_refractivity")),
_two(found.get("tpsa")),
f"{found['hbond_donors']} / {found['hbond_acceptors']}" if found else "",
str(found.get("rotatable_bonds", "")),
]
)
note = (
"logP and MR: Wildman-Crippen; TPSA: Ertl (N, O); HBD/HBA: Lipinski. "
"Calculated estimates, not measurements."
)
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.12, 0.11, 0.07, 0.30, 0.07, 0.07, 0.07, 0.09, 0.07)
) + "@{}" # fmt: skip
lines = [
r"% Requires \usepackage{booktabs}; compile Unicode text with LuaLaTeX or XeLaTeX.",
r"\begingroup\footnotesize\setlength{\tabcolsep}{2pt}",
r"\begin{tabular}{" + columns + "}",
r"\toprule",
]
for index, line in enumerate(values):
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")
[docs]
def property_lines(
document: Document, species: str, fields: Sequence[str] | None = None
) -> list[str]:
"""The lines of a species' property text, one per field."""
chosen = list(fields) if fields is not None else ["formula", "mass", "analysis"]
unknown = [f for f in chosen if f not in FIELDS]
if unknown or not chosen:
raise PropertiesRefused(f"choose fields from {', '.join(FIELDS)}")
row = next((r for r in _rows(document) if r["id"] == species), None)
if row is None:
raise PropertiesRefused(f"the document has no species {species!r}")
if row["formula"] is None:
raise PropertiesRefused(f"species {species} has no structure to describe")
found = row["descriptors"] or {}
text = {
"formula": row["formula"],
"mass": f"M = {row['average_mass']:.2f} g/mol",
"analysis": (row["analysis"] or {}).get("line") or "",
"logp": f"clogP = {_two(found.get('logp'))} (est.)",
"tpsa": f"TPSA = {found.get('tpsa', 0.0):.1f} Ų (est.)",
}
return [text[f] for f in FIELDS if f in chosen and text[f]]
[docs]
def with_property_text(
document: Document, species: str, fields: Sequence[str] | None = None
) -> Document:
"""``document`` with a property text under the first drawn structure of ``species``.
The text goes below the structure, and below its compound number when it
has one; the structure itself does not move.
"""
from vibesketch.chem.species import identity
from vibesketch.document.derive import fragments
from vibesketch.document.ket import read_scene, text_node
lines = property_lines(document, species, fields)
target = document.species_by_id(species)
drawn = next(
(
f
for f in fragments(document)
if f.mol is not None and identity(f.mol).smiles == target.smiles
),
None,
)
if drawn is None:
raise PropertiesRefused(f"species {species} is not drawn on the canvas")
xmin, ymin, xmax, _ = drawn.bounds
below = ymin
for text in read_scene(document.canvas.data).texts:
x, y = text.centre
if xmin - 0.75 <= x <= xmax + 0.75 and ymin - 2.0 <= y < ymin:
below = min(below, y - 0.25) # under the compound number, not over it
height = 0.4 * len(lines)
centre = ((xmin + xmax) / 2, below - _GAP - height / 2)
data: dict[str, Any] = copy.deepcopy(document.canvas.data)
root: dict[str, Any] = data.setdefault("root", {"nodes": []})
nodes: list[Any] = root.setdefault("nodes", [])
nodes.append(text_node("\n".join(lines), centre))
return document.model_copy(update={"canvas": Canvas(format="ket", data=data)})