From 09bef37e54883078f12234fc9af2311c088cd51c Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Mon, 1 Jun 2026 11:49:23 -0400 Subject: [PATCH] feat: cleaned up molprops notebook --- docs/dd/ref/ligand.md | 11 ++-- docs/notebooks/clean/molprops.ipynb | 57 +++++++------------ docs/notebooks/clean/test.ipynb | 23 ++++++++ src/drug_discovery/molprops.py | 12 ++-- src/drug_discovery/structures/ligand.py | 76 ++++++++++++++++++++++--- tests/test_ligand.py | 41 +++++++++++++ tests/test_ligand_set.py | 25 ++++++++ tests/test_molprops_unit.py | 39 +++++++++++++ zensical.toml | 2 + 9 files changed, 229 insertions(+), 57 deletions(-) create mode 100644 tests/test_molprops_unit.py diff --git a/docs/dd/ref/ligand.md b/docs/dd/ref/ligand.md index a2c678b6b..4fb8160b1 100644 --- a/docs/dd/ref/ligand.md +++ b/docs/dd/ref/ligand.md @@ -18,13 +18,14 @@ ## ADMET (molprops) attributes -After you run [`Molprops`](../how-to/ligands.md#predicting-admet-properties) on the ligand, scalar and structured predictions from the Deep Origin molprops tools are stored on the ligand as well as in `properties` and RDKit properties: +After you run [`Molprops`](../how-to/ligands.md#predicting-admet-properties) on the ligand, or load the ligand with `Ligand.from_id` / `LigandSet.from_ids` when values already exist on the platform record, scalar predictions are stored on dedicated attributes as well as in `properties` (tool row keys such as `logS`, `cyp2d6`): -- `log_s`, `log_d`, `log_p` — map to API keys `logS`, `logD`, `logP` -- `herg`, `cyp`, `ames` — nested structures keyed as `hERG`, `cyp`, `ames` in the API response -- `has_pains`, `pains_fragments` — PAINS screening results +- `log_s`, `log_d`, `log_p` — map to tool keys `logS`, `logD`, `logP` (platform columns `logs_predicted`, `logd_predicted`, `log_p`) +- `ames_probability`, `herg_inhibition_probability` — from `ames_probability`, `herg_probability` on the platform record +- `cyp_1a2`, `cyp_2c9`, `cyp_2c19`, `cyp_2d6`, `cyp_3a4` — from `cyp1a2`, …, `cyp3a4` +- `has_pains`, `pains_fragments` — PAINS screening (`pains_flag` on the platform record maps to `has_pains`) -Until molprops has been run successfully (via `Molprops`), these fields remain `None`. +Until molprops has been run or the platform record has pinned values, these fields remain `None`. `pains_fragments` is only available from a fresh `Molprops` run, not from `from_id`. ## Preparation diff --git a/docs/notebooks/clean/molprops.ipynb b/docs/notebooks/clean/molprops.ipynb index 0a12e26b8..748da1b12 100644 --- a/docs/notebooks/clean/molprops.ipynb +++ b/docs/notebooks/clean/molprops.ipynb @@ -26,14 +26,15 @@ ] }, { - "cell_type": "code", - "execution_count": null, - "id": "b9118071-7e60-44bb-86a9-f08f1392e39a", + "cell_type": "markdown", + "id": "4fd04396-e167-48ef-bc6c-bf43720da753", "metadata": {}, - "outputs": [], "source": [ - "from deeporigin import config\n", - "config.set_env(\"dev\")" + "# ADME properties \n", + "\n", + "## Load a ligand set\n", + "\n", + "Here we load the BRD data set. Note that they have a custom column (`r_exp_dg`)" ] }, { @@ -58,23 +59,23 @@ ] }, { - "cell_type": "code", - "execution_count": null, - "id": "06c7fd76-0498-42bb-8578-79b089962062", + "cell_type": "markdown", + "id": "ad2aacfc-c0cb-4b80-96c5-13e04def0cfe", "metadata": {}, - "outputs": [], "source": [ - "mp = Molprops(ligands=ligands)\n", - "mp.tool_version = \"0.5.0\"" + "## Estimate and run molprops\n", + "\n", + "In this section we create a `Molprops` object and use it to estimate and run these ligands:" ] }, { "cell_type": "code", "execution_count": null, - "id": "331a1b15-34f0-4bb6-99f3-2b15ac9d134a", + "id": "06c7fd76-0498-42bb-8578-79b089962062", "metadata": {}, "outputs": [], "source": [ + "mp = Molprops(ligands=ligands)\n", "mp.properties" ] }, @@ -119,42 +120,22 @@ ] }, { - "cell_type": "code", - "execution_count": null, - "id": "7ef3da46-e230-489b-89a9-641ec446a0ef", - "metadata": {}, - "outputs": [], - "source": [ - "client.entities.get_ligand(\"08BT9WM0NYVFY\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cf0c3209-c4b0-4d46-a5db-850dc388aaaf", + "cell_type": "markdown", + "id": "7c2bd6e1-9c4c-49fe-84db-8c3674659d9a", "metadata": {}, - "outputs": [], "source": [ - "client.results.get(compute_job_id=\"824579ec-312b-481f-8147-23526268c307\")" + "Note that computing molprops on ligands inserts those molprops into the Ligands table; this can be verifief by fetching the Ligand from the ligand table:" ] }, { "cell_type": "code", "execution_count": null, - "id": "c83e6607-2ff8-4048-8b9f-e0272aee429e", + "id": "935858f5-9c6b-43c4-ac2f-95f1bda432f5", "metadata": {}, "outputs": [], "source": [ - "client.user_logs.search(\"824579ec-312b-481f-8147-23526268c307\")" + "Ligand.from_id(ligands[0].id).properties" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "935858f5-9c6b-43c4-ac2f-95f1bda432f5", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/notebooks/clean/test.ipynb b/docs/notebooks/clean/test.ipynb index fb49f47bf..787003679 100644 --- a/docs/notebooks/clean/test.ipynb +++ b/docs/notebooks/clean/test.ipynb @@ -19,6 +19,29 @@ ")" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "58e2265c-01c2-4dc4-ac52-85ca285085bd", + "metadata": {}, + "outputs": [], + "source": [ + "from deeporigin.platform import DeepOriginClient\n", + "\n", + "client = DeepOriginClient()\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a73ec46-8d6e-4a28-9e2e-064bb55a398d", + "metadata": {}, + "outputs": [], + "source": [ + "client.files.download_many(files=client.files.list(\"tool-runs/eb2d0ace-8637-485b-be50-3d9159cbfd2a\"))" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/src/drug_discovery/molprops.py b/src/drug_discovery/molprops.py index c759be440..bef2ffa67 100644 --- a/src/drug_discovery/molprops.py +++ b/src/drug_discovery/molprops.py @@ -166,11 +166,10 @@ class Molprops(Execution, SyncExecutableMixin): in place via :meth:`~deeporigin.drug_discovery.structures.ligand.Ligand._apply_molprops_result`. - This is a blocking flow. A normal :meth:`run` does not call - :meth:`~deeporigin.drug_discovery.execution.Execution.update_from_dto`; use - :meth:`run` with ``quote=True`` when you need the platform execution ``id``, - ``status``, and :attr:`~deeporigin.drug_discovery.execution.Execution.estimate` - from a single quotation for all ligands. + This is a blocking flow. :meth:`run` refreshes ``id``, ``status``, and related + execution fields from the platform response via + :meth:`~deeporigin.drug_discovery.execution.Execution.update_from_dto` + (the last batch when :attr:`batch_size` splits the run). Attributes: ligands: Ligands to predict (same order as SMILES sent to the API). @@ -288,6 +287,9 @@ def run(self, *, quote: bool = False) -> Molprops: raw_responses.append(batch_raw) pbar.update(len(batch_ligands)) + if raw_responses: + self.update_from_dto(raw_responses[-1]) + total_cost = 0.0 any_priced = False for raw in raw_responses: diff --git a/src/drug_discovery/structures/ligand.py b/src/drug_discovery/structures/ligand.py index da0d1feea..61ce6e3a3 100644 --- a/src/drug_discovery/structures/ligand.py +++ b/src/drug_discovery/structures/ligand.py @@ -54,6 +54,43 @@ "pains_fragments": "pains_fragments", } +# Molprops API row keys that must not be copied into ``properties`` (entity fields +# or merge keys; see :func:`ligands_to_dataframe` column layout). +_MOLPROPS_ROW_SKIP_PROPERTY_KEYS: frozenset[str] = frozenset( + { + "id", + "ligand_id", + "smiles", + "SMILES", + } +) + +# Platform ligand pinned columns → molprops combined-tool row keys. +# Mirrors ``pinned-columns.registry.ts`` on the data platform. +_PLATFORM_PINNED_TO_MOLPROPS_ROW: dict[str, str] = { + "log_p": "logP", + "pains_flag": "has_pains", + "logd_predicted": "logD", + "logs_predicted": "logS", + "ames_probability": "ames_probability", + "herg_probability": "herg_inhibition_probability", + "cyp1a2": "cyp1a2", + "cyp2c9": "cyp2c9", + "cyp2c19": "cyp2c19", + "cyp2d6": "cyp2d6", + "cyp3a4": "cyp3a4", +} + + +def _molprops_row_from_platform_record(data: dict[str, Any]) -> dict[str, Any]: + """Translate pinned molprops columns on a platform ligand record to tool row keys.""" + + row: dict[str, Any] = {} + for platform_key, tool_key in _PLATFORM_PINNED_TO_MOLPROPS_ROW.items(): + if platform_key in data and data[platform_key] is not None: + row[tool_key] = data[platform_key] + return row + def _sdf_marker_present_in_prefix(path: Path, *, max_bytes: int = 16384) -> bool: """Return True if the start of the file looks like MOL/SDF text.""" @@ -543,6 +580,10 @@ def _from_platform_record( ) -> Self: """Create a Ligand instance from a platform ligand record. + When the record includes pinned molprops columns (``log_p``, ``cyp2d6``, + ``logs_predicted``, etc.), they are applied to ADMET attributes and + :attr:`properties` via :meth:`_apply_molprops_result`. + Args: data: Ligand record returned by the platform entities API. client: DeepOrigin client used to download associated files. @@ -578,6 +619,9 @@ def _from_platform_record( ligand.name = data["name"] if data.get("project_id") is not None: ligand.project_id = str(data["project_id"]) + molprops_row = _molprops_row_from_platform_record(data) + if molprops_row: + ligand._apply_molprops_result(molprops_row) return ligand if mol_file: @@ -600,6 +644,10 @@ def _from_platform_record( if data.get("project_id") is not None: ligand.project_id = str(data["project_id"]) + molprops_row = _molprops_row_from_platform_record(data) + if molprops_row: + ligand._apply_molprops_result(molprops_row) + return ligand @classmethod @@ -1516,6 +1564,8 @@ def _apply_molprops_result(self, props: dict[str, Any]) -> None: if api_key in props: setattr(self, attr_name, props[api_key]) for key, value in props.items(): + if key in _MOLPROPS_ROW_SKIP_PROPERTY_KEYS: + continue self.set_property(key, value) def update_coordinates(self, coordinates: np.ndarray): @@ -1631,26 +1681,34 @@ def ligands_to_dataframe(ligands: list[Ligand]) -> pd.DataFrame: ligands: Ligands to serialize as rows. Returns: - DataFrame with an ``id`` column, union of property columns (except - ``_Name``, ``_SMILES``, ``initial_smiles``, ``id``), and ``SMILES``. + DataFrame with columns ``id``, ``SMILES``, known molprops output keys + (in schema order when present), then any other property keys sorted. """ - # find the union of all properties in all ligands - all_keys = set() + skip_property_keys = { + "_Name", + "_SMILES", + "initial_smiles", + } | _MOLPROPS_ROW_SKIP_PROPERTY_KEYS + + all_keys: set[str] = set() for ligand in ligands: all_keys.update(ligand.properties.keys()) + property_keys = sorted(k for k in all_keys if k not in skip_property_keys) + molprops_keys = [k for k in _MOLPROPS_RESPONSE_TO_ATTR if k in property_keys] + other_keys = sorted(set(property_keys) - set(molprops_keys)) + property_columns: dict[str, list[Any]] = {} - for key in all_keys: - if key in ("_Name", "_SMILES", "initial_smiles", "id"): - continue + for key in molprops_keys + other_keys: property_columns[key] = [ligand.properties.get(key, None) for ligand in ligands] data: dict[str, list[Any]] = {"id": [ligand.id for ligand in ligands]} - data.update(property_columns) data["SMILES"] = [ligand.smiles for ligand in ligands] + data.update(property_columns) - return pd.DataFrame(data) + column_order = ["id", "SMILES", *molprops_keys, *other_keys] + return pd.DataFrame(data)[column_order] def _tool_user_inputs_params(dto: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_ligand.py b/tests/test_ligand.py index e44e17fed..4bf55e4e8 100644 --- a/tests/test_ligand.py +++ b/tests/test_ligand.py @@ -541,6 +541,47 @@ def test_ligand_property_management(): assert ligand.get_property("non_existent") is None +def test_ligand_from_platform_record_hydrates_molprops(): + """Pinned platform molprops columns map to tool row keys and ADMET attrs.""" + from deeporigin.drug_discovery.structures.ligand import ( + _molprops_row_from_platform_record, + ) + + data = { + "id": "0940WE9SZH0VC", + "smiles": "COCCn1cc(-c2cccc(C(=O)N(C)C)c2)c2cc[nH]c2c1=O", + "log_p": 1.377760887145996, + "logs_predicted": -3.0474026203155518, + "logd_predicted": 0.9802079200744629, + "pains_flag": True, + "ames_probability": 0.0012685793917626143, + "herg_probability": 0.17195460200309753, + "cyp2d6": 0.012631930410861969, + "cyp3a4": 0.1912640929222107, + } + row = _molprops_row_from_platform_record(data) + assert row["logP"] == pytest.approx(1.377760887145996) + assert row["logS"] == pytest.approx(-3.0474026203155518) + assert row["logD"] == pytest.approx(0.9802079200744629) + assert row["has_pains"] is True + assert row["herg_inhibition_probability"] == pytest.approx(0.17195460200309753) + + ligand = Ligand.from_smiles(data["smiles"]) + ligand._apply_molprops_result(row) + + assert ligand.log_p == pytest.approx(1.377760887145996) + assert ligand.log_s == pytest.approx(-3.0474026203155518) + assert ligand.log_d == pytest.approx(0.9802079200744629) + assert ligand.has_pains is True + assert ligand.ames_probability == pytest.approx(0.0012685793917626143) + assert ligand.herg_inhibition_probability == pytest.approx(0.17195460200309753) + assert ligand.cyp_2d6 == pytest.approx(0.012631930410861969) + assert ligand.cyp_3a4 == pytest.approx(0.1912640929222107) + assert ligand.properties["cyp2d6"] == pytest.approx(0.012631930410861969) + assert ligand.properties["logS"] == pytest.approx(-3.0474026203155518) + assert ligand.properties["logP"] == pytest.approx(1.377760887145996) + + def test_to_sdf_requires_rehydration_when_remote_path_only(): """to_sdf/to_file must not perform I/O; fail if remote_path set but no local file.""" ligand = Ligand.from_smiles("CCO", name="Ethanol") diff --git a/tests/test_ligand_set.py b/tests/test_ligand_set.py index a39629300..51c4fef88 100644 --- a/tests/test_ligand_set.py +++ b/tests/test_ligand_set.py @@ -648,6 +648,31 @@ def test_ligandset_to_dataframe(): assert len(df) == 2 assert "SMILES" in df.columns assert "logP" in df.columns + assert list(df.columns[:2]) == ["id", "SMILES"] + + +def test_ligandset_to_dataframe_after_molprops(): + """Molprops rows must not duplicate id/SMILES; columns are id, SMILES, then ADMET.""" + from deeporigin.drug_discovery.structures.ligand import LigandSet + + ligand = Ligand.from_smiles("CCO") + ligand.id = "0" + ligand._apply_molprops_result( + { + "ligand_id": "0", + "smiles": "CCO", + "logP": 1.2, + "cyp2c19": 0.5, + } + ) + + df = LigandSet(ligands=[ligand]).to_dataframe() + + assert list(df.columns) == ["id", "SMILES", "logP", "cyp2c19"] + assert "ligand_id" not in df.columns + assert "smiles" not in df.columns + assert df.loc[0, "id"] == "0" + assert df.loc[0, "SMILES"] == "CCO" def test_ligandset_indexing_and_slicing(): diff --git a/tests/test_molprops_unit.py b/tests/test_molprops_unit.py new file mode 100644 index 000000000..7924387f7 --- /dev/null +++ b/tests/test_molprops_unit.py @@ -0,0 +1,39 @@ +"""Unit tests for :class:`~deeporigin.drug_discovery.molprops.Molprops`.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from deeporigin.drug_discovery import Ligand, Molprops +from deeporigin.platform.client import DeepOriginClient +from deeporigin.platform.constants import TOOL_KEYS_AND_VERSIONS + + +def test_molprops_run_syncs_status_from_execution_dto() -> None: + """A normal ``run()`` applies ``update_from_dto`` so status is not left at ``Quoted``.""" + ligand = Ligand.from_smiles("CCO") + client = MagicMock(spec=DeepOriginClient) + job = Molprops(ligands=[ligand], props=["logp"], client=client) + job.status = "Quoted" + job._id = "prior-quote-id" + job._estimate = 0.14 + + mp_cfg = TOOL_KEYS_AND_VERSIONS["mol_props"] + raw_dto: dict = { + "executionId": "exec-run-1", + "tool": {"key": mp_cfg["tool_key"], "version": mp_cfg["tool_version"]}, + "status": "Succeeded", + "quotationResult": {"successfulQuotations": [{"priceTotal": 0.14}]}, + "jobOutputs": {"molprops": [{"ligand_id": "0", "logp": 1.2}]}, + } + rows = [{"ligand_id": "0", "logp": 1.2}] + + with patch( + "deeporigin.drug_discovery.molprops.run_molprops_combined", + return_value=(rows, raw_dto), + ): + job.run() + + assert job.status == "Succeeded" + assert job.id == "exec-run-1" + assert job.cost == 0.14 diff --git a/zensical.toml b/zensical.toml index 3f8a49a0f..6ae1f4695 100644 --- a/zensical.toml +++ b/zensical.toml @@ -53,6 +53,8 @@ nav = [ {"Notebooks" = [ {"Pocketfinder" = "notebooks/html/pocketfinder.html"}, {"Docking a Single Ligand" = "notebooks/html/docking-single-ligand.html"}, + {"Docking Many Ligands" = "notebooks/html/docking-many-ligands.html"}, + {"Molprops" = "notebooks/html/molprops.html"}, {"Projects" = "notebooks/html/projects.html"}, ]}, ]},