Source code for vibesketch.document.properties

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