Skip to content

API reference

The reference below is generated directly from the current Python sources. Names beginning with an underscore are implementation details.

Runtime

AMDockVSRuntime

AMDockVSRuntime()

Bases: AppRuntime

Public AMDockVS runtime on top of the stable MolSuite API.

Methods:

Name Description
general_ligand_count

How many screening ligands are rows here — what the importer asks before offering

set_forced_dependencies

Workflow channel: ids the NEXT submit_job call(s) must also depend on. Lets the

submit_job

Submit a job, auto-waiting for active prerequisite jobs by default.

job_destinations

run_destinations for a job on record: where it may be continued now. Nowhere for one

resume_job

Continue a job that was canceled or interrupted, on executor_name or where it was.

resolve_job_dependencies

Union of explicit depends_on and the ids of active jobs this one should wait for.

wait_for_job

Block until the job has ended. poll_s is not used: the engine tells.

watch_job

Display tqdm progress in a terminal or notebook and return the job status.

watch_jobs

Display concurrent tqdm progress for several jobs of this session.

run_destinations

Where a job may be sent now, as (executor_name, label): the executors that are

Attributes:

Name Type Description
tools ToolAPI

Optional external programs managed by AMDockVS.

shards ShardAPI

Access registered shard containers without wrapping their records.

ligands_store

The row tools' view of the library. The shard tools build their own store.

mode str

Where the screening library lives, read off the data (vs = rows, htpvs = shards).

workflow

The single active workflow (a WorkflowRunner). Panels add steps to this shared

Source code in src/amdockvs/runtime.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def __init__(self):
    super().__init__(
        app_id=AMDOCKVS_APP_ID,
        logger_name=AMDOCKVS_APP_ID,
        project_resources=AMDOCKVS_PROJECT_RESOURCES,
    )
    # A host kept with no environment named is given the one of this app.
    self.workspace.host_env = self._host_env
    amdock_configuration = create_amdock_configuration()
    self._migrate_legacy_app_settings(amdock_configuration)
    self._configuration_sources = [
        self.workspace.settings_manager,
        amdock_configuration,
        create_contact_map_configuration(),
    ]
    self._loader_api: LoaderAPI | None = None
    self._molecule_api: MoleculeAPI | None = None
    self._complex_api: ComplexAPI | None = None
    self._chemistry_api: ChemistryAPI | None = None
    self._qsar_api: QSARAPI | None = None
    self._docking_api: DockingAPI | None = None
    self._binding_sites_api: BindingSiteAPI | None = None
    self._tools_api: ToolAPI | None = None
    self._shards_api: ShardAPI | None = None
    self._ligand_store = None

tools property

tools: ToolAPI

Optional external programs managed by AMDockVS.

shards property

shards: ShardAPI

Access registered shard containers without wrapping their records.

ligands_store property

ligands_store

The row tools' view of the library. The shard tools build their own store.

mode property

mode: str

Where the screening library lives, read off the data (vs = rows, htpvs = shards).

Not stored anywhere: a project becomes a campaign by holding shards, not by having been declared one before it held anything.

workflow property

workflow

The single active workflow (a WorkflowRunner). Panels add steps to this shared instance via runtime.workflow.add_step(...); it persists for the session.

general_ligand_count

general_ligand_count() -> int

How many screening ligands are rows here — what the importer asks before offering to shard, since the two libraries cannot coexist.

Source code in src/amdockvs/runtime.py
299
300
301
302
303
def general_ligand_count(self) -> int:
    """How many screening ligands are rows here — what the importer asks before offering
    to shard, since the two libraries cannot coexist."""
    self._require_active_project()
    return general_ligand_count(self.workspace.project_db)

set_forced_dependencies

set_forced_dependencies(job_ids: Optional[list[str]]) -> None

Workflow channel: ids the NEXT submit_job call(s) must also depend on. Lets the orchestrator inject same-category serialization that the category auto-deps don't cover, without every submit API needing a depends_on parameter. Cleared by passing None.

Source code in src/amdockvs/runtime.py
376
377
378
379
380
def set_forced_dependencies(self, job_ids: Optional[list[str]]) -> None:
    """Workflow channel: ids the NEXT submit_job call(s) must also depend on. Lets the
    orchestrator inject same-category serialization that the category auto-deps don't cover,
    without every submit API needing a depends_on parameter. Cleared by passing None."""
    self._forced_dependencies = list(job_ids) if job_ids else None

submit_job

submit_job(job, *, params: Any = None, executor_name: Optional[str] = None, cpu_required: Optional[int] = None, max_job_cpu: Optional[int] = None, max_inflight_tasks: Optional[int] = None, total_chunks: Optional[int] = None, depends_on: Optional[list[str]] = None, priority: int = 0, result_handler_kwargs: Optional[dict] = None, auto_depends: bool = True) -> str

Submit a job, auto-waiting for active prerequisite jobs by default.

With auto_depends=True (default) the job is held until every active job of its prerequisite categories finishes, so a deferred pipeline can be queued at once. Pass auto_depends=False to submit immediately regardless of running jobs. Returns the key of the job in the project's journal, which is its id everywhere here.

Source code in src/amdockvs/runtime.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def submit_job(
    self,
    job,
    *,
    params: Any = None,
    executor_name: Optional[str] = None,
    cpu_required: Optional[int] = None,
    max_job_cpu: Optional[int] = None,
    max_inflight_tasks: Optional[int] = None,
    total_chunks: Optional[int] = None,
    depends_on: Optional[list[str]] = None,
    priority: int = 0,
    result_handler_kwargs: Optional[dict] = None,
    auto_depends: bool = True,
) -> str:
    """Submit a job, auto-waiting for active prerequisite jobs by default.

    With auto_depends=True (default) the job is held until every active job of its
    prerequisite categories finishes, so a deferred pipeline can be queued at once.
    Pass auto_depends=False to submit immediately regardless of running jobs.
    Returns the key of the job in the project's journal, which is its id everywhere here.
    """
    self._require_active_project()
    depends_on = list(depends_on or [])
    depends_on += [job_id for job_id in getattr(self, "_forced_dependencies", None) or () if job_id not in depends_on]
    if auto_depends:
        depends_on = self.resolve_job_dependencies(job, depends_on) or []
    workspace = self.workspace
    job_config = self._job_config()
    engine_job, on_error = job.engine_job(handler_kwargs=result_handler_kwargs)
    job_params = job.validate_params(params)
    total = total_chunks if total_chunks is not None else job.count_chunks(job_params, config=job_config)
    job_id = uuid4().hex
    engine_ids = self._engine_ids()
    engine_ids[job_id] = workspace.submit(
        engine_job,
        job_params,
        key=job_id,
        executor=self._run_on(job, executor_name),
        # A job that ended in an earlier session has nothing left to wait for.
        after=[engine_ids[dep] for dep in depends_on if dep in engine_ids],
        total=total,
        cpu=cpu_required,
        window=max_inflight_tasks,
        max_cpu=max_job_cpu,
        priority=priority,
        on_error=on_error,
        **({"thread": True} if job.on_thread is not None and job.on_thread(job_params) else {}),
    )
    # Single funnel for "a job just started" UI feedback (the main window plugs a toast in
    # here). May fire from a worker thread, so the hook must be thread-safe (a Qt signal).
    hook = getattr(self, "on_job_submitted", None)
    if hook is not None:
        try:
            hook(str(getattr(job, "name", "") or "Job"), job_id)
        except Exception:  # noqa: BLE001 - feedback must never break a submit
            pass
    return job_id

job_destinations

job_destinations(job_id: str) -> list[tuple[str, str]]

run_destinations for a job on record: where it may be continued now. Nowhere for one that cannot be submitted again.

Source code in src/amdockvs/runtime.py
460
461
462
463
464
465
466
def job_destinations(self, job_id: str) -> list[tuple[str, str]]:
    """`run_destinations` for a job on record: where it may be continued now. Nowhere for one
    that cannot be submitted again."""
    try:
        return self.run_destinations(self._recorded_job(job_id)[0])
    except Exception:  # noqa: BLE001 - not on record, or its declaration is gone
        return []

resume_job

resume_job(job_id: str, executor_name: Optional[str] = None) -> str

Continue a job that was canceled or interrupted, on executor_name or where it was.

A job that is still running is canceled first and waited for, which is how it is moved to another executor: not from a thread that cannot wait. It waits for the jobs its kind waits for, as when it was submitted.

Source code in src/amdockvs/runtime.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def resume_job(self, job_id: str, executor_name: Optional[str] = None) -> str:
    """Continue a job that was canceled or interrupted, on `executor_name` or where it was.

    A job that is still running is canceled first and waited for, which is how it is moved
    to another executor: not from a thread that cannot wait. It waits for the jobs its kind
    waits for, as when it was submitted.
    """
    self._require_active_project()
    job_id = str(job_id)
    job, was_on = self._recorded_job(job_id)
    executor = self._run_on(job, executor_name or was_on)
    self._job_config()
    engine_ids = self._engine_ids()
    waits = [engine_ids[dep] for dep in self.resolve_job_dependencies(job) or ()
             if dep in engine_ids and dep != job_id]
    engine_ids[job_id] = self.workspace.move(job_id, executor, after=waits)
    return job_id

resolve_job_dependencies

resolve_job_dependencies(job, explicit: Optional[list[str]] = None) -> Optional[list[str]]

Union of explicit depends_on and the ids of active jobs this one should wait for.

Source code in src/amdockvs/runtime.py
492
493
494
495
496
497
498
499
500
501
502
503
def resolve_job_dependencies(self, job, explicit: Optional[list[str]] = None) -> Optional[list[str]]:
    """Union of explicit depends_on and the ids of active jobs this one should wait for."""
    category = _job_category(getattr(job, "name", "") or "")
    prereqs = _JOB_PREREQS.get(category or "", ())
    deps: list[str] = list(explicit or [])
    if prereqs or category == "chemistry":
        for status in self.list_jobs(statuses=NON_TERMINAL_JOB_STATUSES):
            active_category = _job_category(status.task_type)
            serial_chemistry = category == "chemistry" and active_category == "chemistry"
            if (active_category in prereqs or serial_chemistry) and status.job_id not in deps:
                deps.append(status.job_id)
    return deps or None

wait_for_job

wait_for_job(job_id: str, *, poll_s: float = 0.2) -> JobStatus

Block until the job has ended. poll_s is not used: the engine tells.

Source code in src/amdockvs/runtime.py
518
519
520
521
522
523
524
525
526
527
def wait_for_job(self, job_id: str, *, poll_s: float = 0.2) -> JobStatus:
    """Block until the job has ended. `poll_s` is not used: the engine tells."""
    self._require_active_project()
    engine_id = self._engine_ids().get(str(job_id))
    if engine_id is None:  # of an earlier session: it ended with it
        status = self.get_job_status(job_id)
        if status is None:
            raise KeyError(f"Unknown job: {job_id}")
        return status
    return self._job_status_from_snapshot(self.workspace.engine.wait(engine_id))

watch_job

watch_job(job_id: str, *, poll_s: float = 0.2) -> JobStatus

Display tqdm progress in a terminal or notebook and return the job status.

Source code in src/amdockvs/runtime.py
529
530
531
def watch_job(self, job_id: str, *, poll_s: float = 0.2) -> JobStatus:
    """Display tqdm progress in a terminal or notebook and return the job status."""
    return self.watch_jobs([job_id], poll_s=poll_s)[str(job_id)]

watch_jobs

watch_jobs(job_ids: Iterable[str], *, poll_s: float = 0.2) -> dict[str, JobStatus]

Display concurrent tqdm progress for several jobs of this session.

Source code in src/amdockvs/runtime.py
533
534
535
536
537
538
539
540
541
def watch_jobs(self, job_ids: Iterable[str], *, poll_s: float = 0.2) -> dict[str, JobStatus]:
    """Display concurrent tqdm progress for several jobs of this session."""
    self._require_active_project()
    from ms_flow.progress import watch_jobs

    job_ids = [str(item) for item in job_ids]
    engine_ids = self._engine_ids()
    snapshots = watch_jobs(self.workspace.engine, [engine_ids[job_id] for job_id in job_ids], poll_s=poll_s)
    return {job_id: self._job_status_from_snapshot(snapshots[engine_ids[job_id]]) for job_id in job_ids}

run_destinations

run_destinations(job=None) -> list[tuple[str, str]]

Where a job may be sent now, as (executor_name, label): the executors that are active, the default one first. With job, only the ones that job can run on.

Source code in src/amdockvs/runtime.py
576
577
578
579
580
581
582
583
584
585
586
def run_destinations(self, job=None) -> list[tuple[str, str]]:
    """Where a job may be sent now, as (executor_name, label): the executors that are
    active, the default one first. With `job`, only the ones that job can run on.
    """
    self._require_active_project()
    rows = [row for row in self.workspace.executors() if row["state"] == "on"]
    # ponytail: `hpc` in the old list of a job is what says its worker needs nothing of
    # this machine but its payload and the files marked in it; one flag when the lists go.
    if job is not None and "hpc" not in job.supported_executors:
        rows = [row for row in rows if row["hosts"] == ["local"]]
    return [(row["name"], row["name"]) for row in sorted(rows, key=lambda row: not row["default"])]

Molecules

api

Classes:

Name Description
MoleculeAPI

MoleculeAPI dataclass

MoleculeAPI(runtime: Any)

Methods:

Name Description
get

Return one molecule without exposing the project session to callers.

details

Load the complete Details-panel projection for a molecule.

list_sets

List saved molecule sets newest first.

resolve_set

Resolve UI/catalog set values into the public typed set reference.

sync_set_membership

Reconcile the denormalized in_set molecule flag.

scope_clause

Opaque SmartTable adapter for a public molecule scope.

evaluate_filter

Count filter outcomes without mutating molecules.

apply_filter

Apply an evaluated filter or persist its matches as a molecule set.

stream

Compatibility entry point; prefer iterating the returned Selection.

library_shape

(the library is sharded, how many ligand rows exist) — two small indexed counts.

shard_counts

Inventory of the sharded library: shards, molecules in them, and how many are prepared.

get

get(molecule_id: int) -> MoleculeRecord | None

Return one molecule without exposing the project session to callers.

Source code in src/amdockvs/molecules/api.py
 99
100
101
102
103
def get(self, molecule_id: int) -> MoleculeRecord | None:
    """Return one molecule without exposing the project session to callers."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        return session.get(MoleculeRecord, int(molecule_id))

details

details(molecule_id: int) -> Details | None

Load the complete Details-panel projection for a molecule.

Source code in src/amdockvs/molecules/api.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def details(self, molecule_id: int) -> Details | None:
    """Load the complete Details-panel projection for a molecule."""
    self.runtime._require_active_project()
    resolved_id = int(molecule_id)
    with self.runtime.workspace.project_db.get_session() as session:
        molecule = session.get(MoleculeRecord, resolved_id)
        if molecule is None:
            return None
        binding_sites = tuple(session.exec(
            select(BindingSite)
            .where(BindingSite.molecule_id == resolved_id)
            .order_by(BindingSite.id)
        ).all())
        receptor_complexes = tuple(session.exec(
            select(ComplexRecord)
            .where(ComplexRecord.receptor_molecule_id == resolved_id)
            .order_by(ComplexRecord.id.desc())
        ).all())
        ligand_complexes = tuple(session.exec(
            select(ComplexRecord)
            .where(ComplexRecord.ligand_molecule_id == resolved_id)
            .order_by(ComplexRecord.id.desc())
        ).all())
        activities = tuple(session.exec(
            select(LigandActivity)
            .where(LigandActivity.molecule_id == resolved_id)
            .order_by(LigandActivity.id.desc())
        ).all())
        current_model = None if molecule.current_model_index is None else session.exec(
            select(MoleculeModel)
            .where(MoleculeModel.molecule_id == resolved_id)
            .where(MoleculeModel.model_index == int(molecule.current_model_index))
        ).first()
    return self.Details(
        molecule=molecule,
        binding_sites=binding_sites,
        receptor_complexes=receptor_complexes,
        ligand_complexes=ligand_complexes,
        activities=activities,
        current_model=current_model,
    )

list_sets

list_sets() -> list[SetRecord]

List saved molecule sets newest first.

Source code in src/amdockvs/molecules/api.py
147
148
149
150
151
def list_sets(self) -> list[SetRecord]:
    """List saved molecule sets newest first."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        return list(session.exec(select(SetRecord).order_by(SetRecord.id.desc())).all())

resolve_set

resolve_set(source: MoleculeSetRef | SetRecord | int) -> MoleculeSetRef

Resolve UI/catalog set values into the public typed set reference.

Source code in src/amdockvs/molecules/api.py
153
154
155
156
157
158
159
160
161
162
def resolve_set(self, source: MoleculeSetRef | SetRecord | int) -> MoleculeSetRef:
    """Resolve UI/catalog set values into the public typed set reference."""
    self.runtime._require_active_project()
    if isinstance(source, MoleculeSetRef):
        return source
    set_id = int(source.id if isinstance(source, SetRecord) else source)
    record = get_molecule_set(self.runtime.workspace.project_db, set_id)
    if record is None:
        raise ValueError(f"Molecule set {set_id} does not exist.")
    return MoleculeSetRef(id=set_id, kind="snapshot")

sync_set_membership

sync_set_membership() -> None

Reconcile the denormalized in_set molecule flag.

Source code in src/amdockvs/molecules/api.py
164
165
166
167
def sync_set_membership(self) -> None:
    """Reconcile the denormalized ``in_set`` molecule flag."""
    self.runtime._require_active_project()
    sync_all_molecule_in_set_flags(self.runtime.workspace.project_db)

scope_clause

scope_clause(scope: MoleculeScope | Selection[Any])

Opaque SmartTable adapter for a public molecule scope.

ORM expressions stay behind the AMDock API; UI callers only pass the returned value to SmartTableView's external-clause hook.

Source code in src/amdockvs/molecules/api.py
169
170
171
172
173
174
175
176
177
178
179
def scope_clause(self, scope: MoleculeScope | Selection[Any]):
    """Opaque SmartTable adapter for a public molecule scope.

    ORM expressions stay behind the AMDock API; UI callers only pass the returned value to
    SmartTableView's external-clause hook.
    """
    self.runtime._require_active_project()
    from amdockvs.molecules import filtering as filter_sql

    conditions = filter_sql.scope_conditions(self.runtime.workspace.project_db, as_molecule_scope(scope))
    return and_(*conditions) if conditions else None

evaluate_filter

evaluate_filter(scope: MoleculeScope | Selection[Any], criteria, *, exclusion_reason_prefix: str = '') -> dict[str, int]

Count filter outcomes without mutating molecules.

Source code in src/amdockvs/molecules/api.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def evaluate_filter(
    self,
    scope: MoleculeScope | Selection[Any],
    criteria,
    *,
    exclusion_reason_prefix: str = "",
) -> dict[str, int]:
    """Count filter outcomes without mutating molecules."""
    self.runtime._require_active_project()
    from amdockvs.molecules import filtering as filter_sql

    conditions = filter_sql.scope_conditions(self.runtime.workspace.project_db, as_molecule_scope(scope))
    prefix = str(exclusion_reason_prefix or "").strip()
    if prefix:
        conditions.append(MoleculeRecord.exclusion_reason.like(f"{prefix}%"))
    return filter_sql.counts(self.runtime.workspace.project_db, conditions, criteria)

apply_filter

apply_filter(scope: MoleculeScope | Selection[Any], criteria, *, action: str, reason: str = '', exclusion_reason_prefix: str = '', set_name: str = '') -> tuple[int, int | None]

Apply an evaluated filter or persist its matches as a molecule set.

Source code in src/amdockvs/molecules/api.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def apply_filter(
    self,
    scope: MoleculeScope | Selection[Any],
    criteria,
    *,
    action: str,
    reason: str = "",
    exclusion_reason_prefix: str = "",
    set_name: str = "",
) -> tuple[int, int | None]:
    """Apply an evaluated filter or persist its matches as a molecule set."""
    self.runtime._require_active_project()
    from amdockvs.molecules import filtering as filter_sql

    normalized_action = str(action or "").strip().lower()
    if normalized_action not in {"enrich", "recover", "tag"}:
        raise ValueError(f"Unsupported molecule-filter action: {action!r}")
    conditions = filter_sql.scope_conditions(self.runtime.workspace.project_db, as_molecule_scope(scope))
    prefix = str(exclusion_reason_prefix or "").strip()
    if prefix:
        conditions.append(MoleculeRecord.exclusion_reason.like(f"{prefix}%"))
    if normalized_action == "tag":
        ids = filter_sql.matched_ids(self.runtime.workspace.project_db, conditions, criteria)
        ref = self.create_set(
            ids,
            name=set_name,
            kind="filter",
            metadata={"source": str(reason or "")},
        )
        return len(ids), int(ref.id)
    return filter_sql.apply_state(
        self.runtime.workspace.project_db,
        conditions,
        criteria,
        activate_matches=normalized_action == "recover",
        exclude_nonmatches=normalized_action == "enrich",
        reason=str(reason or ""),
    )

stream

stream(scope: MoleculeScope | Selection[MoleculeRecord] | None = None) -> Iterator[MoleculeRecord]

Compatibility entry point; prefer iterating the returned Selection.

Source code in src/amdockvs/molecules/api.py
280
281
282
283
284
def stream(self, scope: MoleculeScope | Selection[MoleculeRecord] | None = None) -> Iterator[MoleculeRecord]:
    """Compatibility entry point; prefer iterating the returned Selection."""
    resolved = as_molecule_scope(scope) if scope is not None else as_molecule_scope(self.all())
    assert resolved is not None
    yield from self._stream_scope(resolved)

library_shape

library_shape() -> tuple[bool, int]

(the library is sharded, how many ligand rows exist) — two small indexed counts.

What the catalog bar needs to know and the only thing it needs the db for: Shards is there when there are shards, Ligands lights up when there is any ligand row at all.

Source code in src/amdockvs/molecules/api.py
308
309
310
311
312
313
314
315
316
317
318
def library_shape(self) -> tuple[bool, int]:
    """`(the library is sharded, how many ligand rows exist)` — two small indexed counts.

    What the catalog bar needs to know and the only thing it needs the db for: Shards is
    there when there are shards, Ligands lights up when there is any ligand row at all.
    """
    from amdockvs.molecules.storage import has_shards, ligand_row_count

    self.runtime._require_active_project()
    project_db = self.runtime.workspace.project_db
    return bool(has_shards(project_db)), int(ligand_row_count(project_db))

shard_counts

shard_counts(*, engine: str = 'ad4') -> dict[str, int]

Inventory of the sharded library: shards, molecules in them, and how many are prepared.

Preparation is engine-specific and does not rewrite the chemical inventory.

Source code in src/amdockvs/molecules/api.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def shard_counts(self, *, engine: str = "ad4") -> dict[str, int]:
    """Inventory of the sharded library: shards, molecules in them, and how many are prepared.

    Preparation is engine-specific and does not rewrite the chemical inventory.
    """
    from amdockvs.molecules.storage import ShardStore, iter_prepared_shards, shard_scope_spec

    self.runtime._require_active_project()
    store = ShardStore(self.runtime.workspace.project_db)
    whole = shard_scope_spec(state=None)
    prepared = list(iter_prepared_shards(self.runtime.workspace.project_db, engine=engine))
    return {
        "shards": store.count(whole),
        "records": store.record_count(whole),
        "prepared": len(prepared),
        "prepared_records": sum(int(row.get("n_records") or 0) for row in prepared),
    }

Import

LoaderAPI dataclass

LoaderAPI(runtime: Any)

Methods:

Name Description
shard_ligands

The htpvs import: split the library into shards, materialize nothing.

shard_ligands

shard_ligands(files: Iterable[PathLike], *, shard_size: int | None = None, executor_name: str = 'compute', depends_on: list[str] | None = None, molecule_kind: str = 'small_molecule', prefilter: Mapping[str, Any] | None = None) -> list[str]

The htpvs import: split the library into shards, materialize nothing.

Same feed as load_ligands — the byte-span splitter — with the record cap read as shard size. What changes is the worker: it writes the span back out as a file and registers a screening_shards row instead of parsing molecules into the project db.

Source code in src/amdockvs/io/api.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def shard_ligands(
    self,
    files: Iterable[PathLike],
    *,
    shard_size: int | None = None,
    executor_name: str = "compute",
    depends_on: list[str] | None = None,
    molecule_kind: str = "small_molecule",
    prefilter: Mapping[str, Any] | None = None,
) -> list[str]:
    """The `htpvs` import: split the library into shards, materialize nothing.

    Same feed as `load_ligands` — the byte-span splitter — with the record cap read as
    shard size. What changes is the worker: it writes the span back out as a file and
    registers a `screening_shards` row instead of parsing molecules into the project db.
    """
    normalized_files = normalize_files(files, label="ligand files")
    if not normalized_files:
        return []
    check_library_target(self.runtime.workspace.project_db, target=LIBRARY_SHARDS)
    records = (
        default_shard_size(runtime=self.runtime)
        if shard_size is None
        else int(shard_size)
    )
    records = max(1, min(records, MAX_SHARD_RECORDS))
    params: dict[str, Any] = {
        "file_paths": [str(file_path) for file_path in normalized_files],
        "batch_size": records,
        "molecule_kind": str(molecule_kind or "small_molecule"),
    }
    if prefilter:
        params["prefilter"] = dict(prefilter)
    return [
        self._submit_job(
            shard_ligands_job,
            params=params,
            executor_name=executor_name,
            depends_on=depends_on,
            # ramp=False, like the feed: an over-declared total never completes.
            total_chunks=_total_import_chunks(normalized_files, batch_size=records, ramp=False),
            max_inflight_tasks=import_max_inflight(self.runtime),
            # The queue that cuts the shards lives here, in the parent: it needs the
            # project's shard directory and its database, neither of which a job spec
            # knows at declaration time.
            result_handler_kwargs={
                "project_db": self.runtime.workspace.project_db,
                "shard_dir": self.runtime.get_project_resource_path(RESOURCE_SHARDS),
                "shard_size": records,
                "generation_id": self._import_generation_id(),
            },
        )
    ]

Chemistry

ChemistryAPI dataclass

ChemistryAPI(runtime: Any)

Methods:

Name Description
run_ligand_pipeline

Run several chemistry steps in one pass over the active screening library.

run_shard_pipeline

The same steps as run_ligand_pipeline, over the shards of an htpvs project.

predict_receptors

Predict protein structures from sequence with the ESMFold API (thread executor).

run_ligand_pipeline

run_ligand_pipeline(steps: Sequence[str | tuple[str, dict[str, Any]]], *, ligands: MoleculeSetRef | MoleculeScope | int | None = None, structure_source: str = 'current', batch_size: int = 128, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None, wait: bool = False, shard_state: str = PENDING) -> str | JobStatus

Run several chemistry steps in one pass over the active screening library.

run_ligand_pipeline(["standardize", "protonate", "generate_3d"]) reads and writes every ligand once; the three single-step jobs read and rewrite the whole library three times.

With no explicit row scope, a sharded library dispatches whole shards automatically. Passing ligands always selects the row adapter for curated/reference molecules.

ponytail: the single-step methods below validate their own arguments (pH range, method names, tool installs). Here the step params go through as given and the worker raises.

Source code in src/amdockvs/chemistry/api.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def run_ligand_pipeline(
    self,
    steps: Sequence[str | tuple[str, dict[str, Any]]],
    *,
    ligands: MoleculeSetRef | MoleculeScope | int | None = None,
    structure_source: str = "current",
    batch_size: int = 128,
    executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
    depends_on: list[str] | None = None,
    wait: bool = False,
    shard_state: str = ShardState.PENDING,
) -> str | JobStatus:
    """Run several chemistry steps in one pass over the active screening library.

    `run_ligand_pipeline(["standardize", "protonate", "generate_3d"])` reads and writes every
    ligand once; the three single-step jobs read and rewrite the whole library three times.

    With no explicit row scope, a sharded library dispatches whole shards automatically.
    Passing ``ligands`` always selects the row adapter for curated/reference molecules.

    ponytail: the single-step methods below validate their own arguments (pH range, method
    names, tool installs). Here the step params go through as given and the worker raises.
    """
    normalized = normalize_steps(steps)
    if not normalized:
        raise ValueError("run_ligand_pipeline needs at least one step.")
    for name, _params in normalized:
        if name not in LIGAND_STEPS:
            raise ValueError(f"Unsupported ligand chemistry operation: {name}")
    normalized = [
        (name, self._protonate_params(**params) if name == "protonate" else params)
        for name, params in normalized
    ]
    if ligands is None:
        self.runtime._require_active_project()
        if has_shards(self.runtime.workspace.project_db):
            return self.run_shard_pipeline(
                normalized,
                state=shard_state,
                executor_name=executor_name,
                depends_on=depends_on,
                wait=wait,
            )
    return self._submit_ligand_operation(
        [[name, params] for name, params in normalized],
        ligands=ligands,
        params={},
        structure_source=structure_source,
        batch_size=batch_size,
        executor_name=executor_name,
        depends_on=depends_on,
        wait=wait,
    )

run_shard_pipeline

run_shard_pipeline(steps: Sequence[str | tuple[str, dict[str, Any]]], *, state: str = PENDING, params: dict[str, Any] | None = None, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None, wait: bool = False) -> str | JobStatus

The same steps as run_ligand_pipeline, over the shards of an htpvs project.

One shard per chunk, one file in and one file out. The run writes into a new generation and only switches to it if it finishes, so a crashed run changes nothing and the retry simply starts over from the generation that is still the library.

Source code in src/amdockvs/chemistry/api.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def run_shard_pipeline(
    self,
    steps: Sequence[str | tuple[str, dict[str, Any]]],
    *,
    state: str = ShardState.PENDING,
    params: dict[str, Any] | None = None,
    executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
    depends_on: list[str] | None = None,
    wait: bool = False,
) -> str | JobStatus:
    """The same steps as `run_ligand_pipeline`, over the shards of an `htpvs` project.

    One shard per chunk, one file in and one file out. The run writes into a new
    generation and only switches to it if it finishes, so a crashed run changes nothing
    and the retry simply starts over from the generation that is still the library.
    """
    normalized = normalize_steps(steps)
    if not normalized:
        raise ValueError("run_shard_pipeline needs at least one step.")
    for name, _params in normalized:
        if name not in LIGAND_STEPS:
            raise ValueError(f"Unsupported ligand chemistry operation: {name}")
    normalized = [
        (name, self._protonate_params(**params) if name == "protonate" else params)
        for name, params in normalized
    ]
    if any(name == "conformers" for name, _params in normalized):
        raise ValueError(
            "Sharded conformer ensembles are not supported: one shard record currently "
            "stores one molecular structure. Generate conformers after materialization."
        )
    self.runtime._require_active_project()
    project_db = self.runtime.workspace.project_db
    # Opened here, before anything runs: the feed needs its output directory and the
    # result handler needs its id, and neither can invent one of its own without the two
    # disagreeing about where the generation lives.
    label = "+".join(name for name, _ in normalized)
    shards_root = Path(self.runtime.get_project_resource_path(RESOURCE_SHARDS))
    generation_id = create_generation(project_db, step=label, shard_dir=shards_root)
    job_id = self.runtime.submit_job(
        shard_chemistry_job,
        params=ShardChemistryJobParams(
            operation=[[name, step_params] for name, step_params in normalized],
            params=dict(params or {}),
            state=state,
            generation_id=generation_id,
            output_dir=str(shards_root / f"gen_{generation_id:04d}_{label}"),
        ).model_dump(mode="python"),
        executor_name=executor_name,
        depends_on=depends_on,
        result_handler_kwargs={"project_db": project_db, "generation_id": generation_id},
    )
    return self.runtime.wait_for_job(job_id) if wait else job_id

predict_receptors

predict_receptors(*, receptors: MoleculeSetRef | MoleculeScope | int | None = None, model: str = FAST_MODEL, force: bool = False, depends_on: list[str] | None = None, wait: bool = False) -> str | JobStatus

Predict protein structures from sequence with the ESMFold API (thread executor).

Only proteins without 3D by default; force=True predicts again and repoints current_path to the new model. The token comes from ESM_API_KEY and is never stored.

Source code in src/amdockvs/chemistry/api.py
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
def predict_receptors(
        self,
        *,
        receptors: MoleculeSetRef | MoleculeScope | int | None = None,
        model: str = FAST_MODEL,
        force: bool = False,
        depends_on: list[str] | None = None,
        wait: bool = False,
) -> str | JobStatus:
    """Predict protein structures from sequence with the ESMFold API (thread executor).

    Only proteins without 3D by default; `force=True` predicts again and repoints current_path
    to the new model. The token comes from ESM_API_KEY and is never stored.
    """
    self.runtime._require_active_project()
    if not api_token():
        raise RuntimeError(f"{ESM_TOKEN_ENV} is not set: paste your Biohub API token first.")
    receptor_set_ref = None if receptors is None or is_molecule_scope(receptors) else ensure_molecule_set_ref(self.runtime, receptors, name="chemistry_receptor_predict_input")
    receptor_scope = scope_payload(receptors) if is_molecule_scope(receptors) else {}
    receptor_filters = dict(receptor_scope.get("filters") or {})
    if "molecule_type" not in receptor_filters:
        receptor_filters["is_receptor"] = True
    if receptor_scope.get("limit") is not None:
        receptor_filters["_limit"] = receptor_scope.get("limit")
    params = ReceptorPredictionJobParams(
        model=model,
        force=bool(force),
        receptor_set_id=None if receptor_set_ref is None else int(receptor_set_ref.id),
        receptor_filters=receptor_filters if force else {**receptor_filters, "has_3d": False},
    )
    project_db = self.runtime.workspace.project_db
    total_items = db_count(project_db, prediction_scope_spec(params))
    # An empty scope with proteins in it means they all have 3D already. An empty scope with
    # no proteins at all may be an import still running: submit and let the job wait for it.
    if total_items == 0 and not force and db_count(project_db, prediction_scope_spec(params.model_copy(update={"receptor_filters": receptor_filters}))):
        raise ValueError("Every protein in scope already has a 3D structure; tick Force to predict again.")
    job_id = self.runtime.submit_job(
        receptor_prediction_job,
        params=params.model_dump(mode="python"),
        executor_name="thread",
        depends_on=depends_on,
        total_chunks=total_items or None,
    )
    if wait:
        return self.runtime.wait_for_job(job_id)
    return job_id

Complexes

ComplexAPI dataclass

ComplexAPI(runtime: Any)

Methods:

Name Description
details

Load the complete Details-panel projection for a receptor-ligand pair.

details

details(complex_ref: ComplexRef | int) -> Details | None

Load the complete Details-panel projection for a receptor-ligand pair.

Source code in src/amdockvs/complexes/api.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def details(self, complex_ref: ComplexRef | int) -> Details | None:
    """Load the complete Details-panel projection for a receptor-ligand pair."""
    self.runtime._require_active_project()
    complex_id = int(complex_ref.id if isinstance(complex_ref, ComplexRef) else complex_ref)
    with self.runtime.workspace.project_db.get_session() as session:
        pair = session.get(ComplexRecord, complex_id)
        if pair is None:
            return None
        receptor = session.get(MoleculeRecord, int(pair.receptor_molecule_id or 0))
        ligand = session.get(MoleculeRecord, int(pair.ligand_molecule_id or 0))
        activity = (
            session.get(LigandActivity, int(pair.activity_id or 0))
            if int(pair.activity_id or 0) > 0
            else None
        )
    return self.Details(complex=pair, receptor=receptor, ligand=ligand, activity=activity)

QSAR

QSARAPI dataclass

QSARAPI(runtime: Any)

Methods:

Name Description
set_activity

Create or edit a ligand's activity for an endpoint (activity_type). With

delete_activity

Delete a ligand's activities (all, or only for one endpoint). Returns count removed.

activity_rows

Activities joined with the ligand name, for the editor table:

list_endpoints

Distinct activity endpoints present in the project.

load_activities

Bulk-load activities from a CSV/TSV.

load_activity_matrix

Load a wide CSV where several columns are each an activity endpoint (e.g. Tox21's 12

endpoint_kinds

{endpoint: 'categorical'|'continuous'} so the model UI can default the task.

compute_descriptors

Submit the descriptor job. only_missing=True skips ligands that already have

compute_fingerprints

Submit a Morgan-fingerprint job (also fills physchem descriptors as a side effect).

applicability_domain

Per-ligand applicability domain by ECFP4 similarity: mean Tanimoto to the k nearest

correlation_matrix

Pearson correlation matrix across the descriptor block's features (over the project's

split_preview

Train/test label distribution for an endpoint WITHOUT training, so you can see whether the

train

Fit a model template from the ligands of molecule_set that have an activity for

load_external_model

Register a pre-built model artifact (a joblib FittedModel) for prediction. No

roc_curve_points

(fpr, tpr) ROC points + AUC for a binary classification model. subset 'test' (held-out —

prediction_rows

Predictions joined with the ligand name, for the Predictions table:

cut_predictions

Use a model's predictions as a filter: keep the ligands at or beyond cutoff (>= when

evaluate

Score a labeled molecule set against a model and return task-appropriate metrics.

model_subsets

{ligand_id: 'train'|'test'} for a trained model's split (empty if not recorded).

atom_contributions

Per-atom contribution to an ECFP4 model's prediction (StarDrop's "glowing molecule").

set_activity

set_activity(*, ligand_id: int, endpoint: str, value: float, unit: str = '', source: str = '', description: str = '', replace: bool = True) -> ActivityRecord

Create or edit a ligand's activity for an endpoint (activity_type). With replace=True (default) it overwrites existing values for that (ligand, endpoint).

Source code in src/amdockvs/qsar/api.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def set_activity(
    self,
    *,
    ligand_id: int,
    endpoint: str,
    value: float,
    unit: str = "",
    source: str = "",
    description: str = "",
    replace: bool = True,
) -> ActivityRecord:
    """Create or edit a ligand's activity for an endpoint (activity_type). With
    replace=True (default) it overwrites existing values for that (ligand, endpoint)."""
    self.runtime._require_active_project()
    endpoint_text = str(endpoint or "").strip()
    if not endpoint_text:
        raise ValueError("set_activity requires endpoint.")
    with self.runtime.workspace.project_db.get_session() as session:
        molecule = session.get(MoleculeRecord, int(ligand_id))
        if molecule is None:
            raise ValueError(f"Molecule {ligand_id} does not exist.")
        if replace:
            existing = session.exec(
                select(ActivityRecord)
                .where(ActivityRecord.molecule_id == int(ligand_id))
                .where(ActivityRecord.activity_type == endpoint_text)
            ).all()
            for row in existing:
                session.delete(row)
        record = ActivityRecord(
            molecule_id=int(ligand_id),
            value=float(value),
            unit=str(unit or ""),
            activity_type=endpoint_text,
            description=str(description or ""),
            source=str(source or ""),
        )
        session.add(record)
        molecule.has_activity = True
        session.add(molecule)
        session.commit()
        session.refresh(record)
        return record

delete_activity

delete_activity(*, ligand_id: int, endpoint: str | None = None) -> int

Delete a ligand's activities (all, or only for one endpoint). Returns count removed.

Source code in src/amdockvs/qsar/api.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def delete_activity(self, *, ligand_id: int, endpoint: str | None = None) -> int:
    """Delete a ligand's activities (all, or only for one endpoint). Returns count removed."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        statement = select(ActivityRecord).where(ActivityRecord.molecule_id == int(ligand_id))
        if endpoint is not None:
            statement = statement.where(ActivityRecord.activity_type == str(endpoint))
        rows = session.exec(statement).all()
        for row in rows:
            session.delete(row)
        remaining = session.exec(
            select(ActivityRecord).where(ActivityRecord.molecule_id == int(ligand_id))
        ).first()
        molecule = session.get(MoleculeRecord, int(ligand_id))
        if molecule is not None:
            molecule.has_activity = remaining is not None
            session.add(molecule)
        session.commit()
        return len(rows)

activity_rows

activity_rows(*, endpoint: str | None = None) -> list[dict[str, Any]]

Activities joined with the ligand name, for the editor table: [{molecule_id, name, value, unit}] ordered by ligand id.

Source code in src/amdockvs/qsar/api.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
def activity_rows(self, *, endpoint: str | None = None) -> list[dict[str, Any]]:
    """Activities joined with the ligand name, for the editor table:
    [{molecule_id, name, value, unit}] ordered by ligand id."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        statement = select(ActivityRecord)
        if endpoint is not None:
            statement = statement.where(ActivityRecord.activity_type == str(endpoint))
        rows = list(session.exec(statement.order_by(ActivityRecord.molecule_id)))
        names = {
            int(m.id): str(m.name or "")
            for m in session.exec(
                select(MoleculeRecord).where(MoleculeRecord.id.in_([int(r.molecule_id) for r in rows] or [0]))
            )
        }
    return [
        {"molecule_id": int(r.molecule_id), "name": names.get(int(r.molecule_id), ""),
         "value": float(r.value), "unit": str(r.unit or "")}
        for r in rows
    ]

list_endpoints

list_endpoints() -> list[str]

Distinct activity endpoints present in the project.

Source code in src/amdockvs/qsar/api.py
328
329
330
331
332
333
334
def list_endpoints(self) -> list[str]:
    """Distinct activity endpoints present in the project."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        return sorted({
            str(r) for r in session.exec(select(ActivityRecord.activity_type)).all() if str(r or "").strip()
        })

load_activities

load_activities(file: PathLike, *, endpoint: str, value_key: str = 'value', match_by: str = 'auto', key_column: str | None = None, unit: str = '', unit_key: str | None = None, transform: str | None = None, kind: str = 'continuous', source: str = '', replace: bool = True, delimiter: str | None = None, import_missing: bool = False, executor_name: str = 'compute') -> dict[str, Any]

Bulk-load activities from a CSV/TSV.

match_by: 'auto' (default — prefer InChIKey, then SMILES, then name), 'inchikey', 'smiles' or 'name'. Structural keys match the stored representations (InChIKey is robust to SMILES formatting). transform (e.g. 'pIC50'/'pKi') converts a concentration value+unit to pX = -log10(M) — the modeling endpoint — keeping the raw value in the description. replace=True wipes prior activities for the matched ligands+endpoint first (idempotent reload). import_missing=True first imports (via the real ligand importer) any CSV row whose molecule isn't in the project yet, so a fresh CSV becomes molecules+activities in one call.

Source code in src/amdockvs/qsar/api.py
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
def load_activities(
    self,
    file: PathLike,
    *,
    endpoint: str,
    value_key: str = "value",
    match_by: str = "auto",
    key_column: str | None = None,
    unit: str = "",
    unit_key: str | None = None,
    transform: str | None = None,
    kind: str = "continuous",
    source: str = "",
    replace: bool = True,
    delimiter: str | None = None,
    import_missing: bool = False,
    executor_name: str = "compute",
) -> dict[str, Any]:
    """Bulk-load activities from a CSV/TSV.

    match_by: 'auto' (default — prefer InChIKey, then SMILES, then name), 'inchikey', 'smiles'
    or 'name'. Structural keys match the stored representations (InChIKey is robust to SMILES
    formatting). transform (e.g. 'pIC50'/'pKi') converts a concentration value+unit to
    pX = -log10(M) — the modeling endpoint — keeping the raw value in the description.
    replace=True wipes prior activities for the matched ligands+endpoint first (idempotent reload).
    import_missing=True first imports (via the real ligand importer) any CSV row whose molecule
    isn't in the project yet, so a fresh CSV becomes molecules+activities in one call.
    """
    self.runtime._require_active_project()
    file_path = Path(file).expanduser().resolve()
    if not file_path.exists():
        raise FileNotFoundError(file_path)
    if import_missing:
        self._import_missing_molecules(
            file_path, match_by=match_by, key_column=key_column,
            delimiter=delimiter, executor_name=executor_name,
        )
    endpoint_text = str(endpoint or "").strip()
    if not endpoint_text:
        raise ValueError("load_activities requires endpoint.")
    if delimiter is None:
        delimiter = "\t" if file_path.suffix.lower() == ".tsv" else ","
    loaded = skipped_missing = skipped_invalid = 0
    with self.runtime.workspace.project_db.get_session() as session:
        with file_path.open("r", encoding="utf-8", newline="") as handle:
            reader = csv.DictReader(handle, delimiter=delimiter)
            if reader.fieldnames is None:
                raise ValueError(f"Activity file has no header: {file_path}")
            mode, key_col = self._detect_match(reader.fieldnames, match_by, key_column)
            name_to_id: dict[str, int] = {}
            inchikey_to_id: dict[str, int] = {}
            smiles_to_id: dict[str, int] = {}
            if mode == "name":
                name_to_id = {
                    str(r.name): int(r.id)
                    for r in session.exec(select(MoleculeRecord).where(MoleculeRecord.is_ligand.is_(True)))
                    if r.id is not None
                }
            else:
                inchikey_to_id, smiles_to_id = self._structural_ligand_index(session)

            rows_to_add: list[tuple[int, float, str, str]] = []  # (lid, value, unit, description)
            for raw in reader:
                key = str(raw.get(key_col) or "").strip()
                if mode == "name":
                    lid = name_to_id.get(key)
                elif mode == "inchikey":
                    lid = inchikey_to_id.get(key.upper())
                else:  # smiles — canonicalize the CSV smiles, prefer InChIKey identity
                    canon, ikey = _structural_keys_from_smiles(key)
                    lid = (inchikey_to_id.get(ikey) if ikey else None) or (smiles_to_id.get(canon) if canon else None)
                if lid is None:
                    skipped_missing += 1
                    continue
                try:
                    raw_value = float(str(raw.get(value_key) or "").strip())
                except ValueError:
                    skipped_invalid += 1
                    continue
                row_unit = str((raw.get(unit_key) if unit_key else unit) or "").strip()
                if transform:
                    transformed = _to_pchem(raw_value, row_unit)
                    if transformed is None:
                        skipped_invalid += 1
                        continue
                    rows_to_add.append((lid, transformed, transform, f"raw={raw_value} {row_unit}".strip()))
                else:
                    rows_to_add.append((lid, raw_value, row_unit, ""))

        # 'auto' -> categorical when every value is a 0/1 label, else continuous.
        effective_kind = str(kind or "continuous")
        if effective_kind == "auto":
            effective_kind = "categorical" if rows_to_add and all(
                v in (0.0, 1.0) for _, v, *_ in rows_to_add
            ) else "continuous"

        touched = {lid for lid, *_ in rows_to_add}
        if replace and touched:
            for stale in session.exec(
                select(ActivityRecord)
                .where(ActivityRecord.activity_type == endpoint_text)
                .where(ActivityRecord.molecule_id.in_(touched))
            ).all():
                session.delete(stale)
        for lid, value, value_unit, description in rows_to_add:
            session.add(
                ActivityRecord(
                    molecule_id=lid,
                    value=value,
                    unit=value_unit,
                    activity_type=endpoint_text,
                    kind=effective_kind,
                    source=str(source or file_path.name),
                    description=description,
                )
            )
            loaded += 1
        for lid in touched:
            molecule = session.get(MoleculeRecord, lid)
            if molecule is not None:
                molecule.has_activity = True
                session.add(molecule)
        session.commit()
    return {
        "file": str(file_path),
        "endpoint": endpoint_text,
        "kind": effective_kind,
        "match_by": mode,
        "key_column": key_col,
        "transform": transform or "",
        "loaded": loaded,
        "skipped_missing_ligand": skipped_missing,
        "skipped_invalid_value": skipped_invalid,
    }

load_activity_matrix

load_activity_matrix(file: PathLike, *, value_columns: Sequence[str] | None = None, kind: str = 'auto', match_by: str = 'name', key_column: str | None = None, source: str = '', replace: bool = True, delimiter: str | None = None, import_missing: bool = False, executor_name: str = 'compute') -> dict[str, Any]

Load a wide CSV where several columns are each an activity endpoint (e.g. Tox21's 12 assays). value_columns=None auto-picks every column except the structural/name keys. Each column becomes one endpoint (activity_type=column); empty cells are skipped so a sparse assay matrix loads cleanly. kind 'auto' (default) records categorical for 0/1-label columns and continuous otherwise, per column; pass 'categorical'/'continuous' to force it. import_missing=True first imports molecules for rows not yet in the project (one importer pass), then loads every endpoint — turning a raw CSV into molecules+activities in one call.

Source code in src/amdockvs/qsar/api.py
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
def load_activity_matrix(
    self,
    file: PathLike,
    *,
    value_columns: Sequence[str] | None = None,
    kind: str = "auto",
    match_by: str = "name",
    key_column: str | None = None,
    source: str = "",
    replace: bool = True,
    delimiter: str | None = None,
    import_missing: bool = False,
    executor_name: str = "compute",
) -> dict[str, Any]:
    """Load a wide CSV where several columns are each an activity endpoint (e.g. Tox21's 12
    assays). value_columns=None auto-picks every column except the structural/name keys.
    Each column becomes one endpoint (activity_type=column); empty cells are skipped so a
    sparse assay matrix loads cleanly. kind 'auto' (default) records categorical for 0/1-label
    columns and continuous otherwise, per column; pass 'categorical'/'continuous' to force it.
    import_missing=True first imports molecules for rows not yet in the project (one importer
    pass), then loads every endpoint — turning a raw CSV into molecules+activities in one call."""
    file_path = Path(file).expanduser().resolve()
    if not file_path.exists():
        raise FileNotFoundError(file_path)
    if delimiter is None:
        delimiter = "\t" if file_path.suffix.lower() == ".tsv" else ","
    imported = 0
    if import_missing:
        imported = self._import_missing_molecules(
            file_path, match_by=match_by, key_column=key_column,
            delimiter=delimiter, executor_name=executor_name,
        )
    with file_path.open("r", encoding="utf-8", newline="") as handle:
        header = csv.DictReader(handle, delimiter=delimiter).fieldnames or []
    if not header:
        raise ValueError(f"Activity file has no header: {file_path}")
    structural = {"smiles", "canonical_smiles", "smiles_canonical", "inchikey", "inchi_key",
                  "inchi key", "name", "id", "title", "compound", "molecule", "mol_id", "molecule_id"}
    if value_columns is None:
        value_columns = [c for c in header if c.strip().lower() not in structural and c != key_column]
    results = [
        self.load_activities(
            file_path, endpoint=col, value_key=col, kind=kind, match_by=match_by,
            key_column=key_column, source=source, replace=replace, delimiter=delimiter,
        )
        for col in value_columns
    ]
    return {
        "file": str(file_path),
        "endpoints": list(value_columns),
        "imported_molecules": imported,
        "loaded": sum(r["loaded"] for r in results),
        "per_endpoint": {r["endpoint"]: r["loaded"] for r in results},
        "kinds": {r["endpoint"]: r["kind"] for r in results},
    }

endpoint_kinds

endpoint_kinds() -> dict[str, str]

{endpoint: 'categorical'|'continuous'} so the model UI can default the task.

Source code in src/amdockvs/qsar/api.py
663
664
665
666
667
668
669
670
def endpoint_kinds(self) -> dict[str, str]:
    """{endpoint: 'categorical'|'continuous'} so the model UI can default the task."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        rows = session.exec(
            select(ActivityRecord.activity_type, ActivityRecord.kind).distinct()
        ).all()
    return {str(ep): str(k or "continuous") for ep, k in rows if str(ep or "").strip()}

compute_descriptors

compute_descriptors(*, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, only_missing: bool = True, batch_size: int = 1000, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None, compute_fingerprints: bool = False, fp_radius: int = 2, fp_nbits: int = 2048) -> str

Submit the descriptor job. only_missing=True skips ligands that already have descriptors computed (MoleculeRecord.mw populated). With compute_fingerprints=True it also stores a Morgan fingerprint per ligand, and only_missing then means "lacks that fingerprint" so FPs can be added to already-described ligands.

Source code in src/amdockvs/qsar/api.py
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
def compute_descriptors(
    self,
    *,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    only_missing: bool = True,
    batch_size: int = 1000,
    executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
    depends_on: list[str] | None = None,
    compute_fingerprints: bool = False,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
) -> str:
    """Submit the descriptor job. only_missing=True skips ligands that already have
    descriptors computed (MoleculeRecord.mw populated). With compute_fingerprints=True it
    also stores a Morgan fingerprint per ligand, and only_missing then means "lacks that
    fingerprint" so FPs can be added to already-described ligands."""
    self.runtime._require_active_project()
    molecule_set_ref = (
        None
        if molecule_set is None or is_molecule_scope(molecule_set)
        else ensure_molecule_set_ref(self.runtime, molecule_set, name="qsar_descriptor_input")
    )
    molecule_scope = scope_payload(molecule_set) if is_molecule_scope(molecule_set) else {}
    filters = apply_workflow_filters(
        {**dict(molecule_scope.get("filters") or {}), "excluded": False},
        workflow=QSAR_WORKFLOW,
        role="ligand",
    )
    params = DescriptorJobParams(
        batch_size=batch_size,
        molecule_set_id=None if molecule_set_ref is None else int(molecule_set_ref.id),
        molecule_filters=filters,
        only_missing=bool(only_missing),
        compute_fingerprints=bool(compute_fingerprints),
        fp_radius=int(fp_radius),
        fp_nbits=int(fp_nbits),
    )
    return self.runtime.submit_job(
        calculate_molecule_descriptors_job,
        params=params.model_dump(mode="python"),
        executor_name=executor_name,
        depends_on=depends_on,
    )

compute_fingerprints

compute_fingerprints(*, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, only_missing: bool = True, radius: int = 2, nbits: int = 2048, batch_size: int = 1000, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None) -> str

Submit a Morgan-fingerprint job (also fills physchem descriptors as a side effect).

Source code in src/amdockvs/qsar/api.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
def compute_fingerprints(
    self,
    *,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    only_missing: bool = True,
    radius: int = 2,
    nbits: int = 2048,
    batch_size: int = 1000,
    executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
    depends_on: list[str] | None = None,
) -> str:
    """Submit a Morgan-fingerprint job (also fills physchem descriptors as a side effect)."""
    return self.compute_descriptors(
        molecule_set=molecule_set,
        only_missing=only_missing,
        batch_size=batch_size,
        executor_name=executor_name,
        depends_on=depends_on,
        compute_fingerprints=True,
        fp_radius=radius,
        fp_nbits=nbits,
    )

applicability_domain

applicability_domain(*, model: QSARModelRef | int, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, k: int = 5, threshold: float = 0.3, fp_radius: int = 2, fp_nbits: int = 2048) -> list[dict[str, Any]]

Per-ligand applicability domain by ECFP4 similarity: mean Tanimoto to the k nearest training ligands of model; in_domain = that mean >= threshold. Answers "is this prediction an interpolation or an extrapolation?". Needs Morgan fingerprints computed.

Source code in src/amdockvs/qsar/api.py
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
def applicability_domain(
    self,
    *,
    model: QSARModelRef | int,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    k: int = 5,
    threshold: float = 0.3,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
) -> list[dict[str, Any]]:
    """Per-ligand applicability domain by ECFP4 similarity: mean Tanimoto to the k nearest
    *training* ligands of ``model``; in_domain = that mean >= threshold. Answers "is this
    prediction an interpolation or an extrapolation?". Needs Morgan fingerprints computed."""
    self.runtime._require_active_project()
    train_ids = [int(mid) for mid, sub in self.model_subsets(model=model).items() if sub == "train"]
    ligands = self._ligands(molecule_set)
    with self.runtime.workspace.project_db.get_session() as session:
        if not train_ids:  # model recorded no split → use every labeled training ligand as reference
            record = session.get(QSARModelRecord, int(model.id if hasattr(model, "id") else model))
            train_ids = [int(m) for m in ((record.metrics or {}).get("usable_ligands") or [])] if record else []
        fp_map = self._fingerprint_matrix(
            session, train_ids + [int(r.id or 0) for r in ligands], radius=fp_radius, nbits=fp_nbits
        )
        names = {int(r.id or 0): str(r.name or "") for r in ligands}
    train_fps = [fp_map[i] for i in train_ids if i in fp_map]
    if not train_fps:
        raise ValueError("No training fingerprints — compute Morgan fingerprints (ECFP4) first.")
    train_matrix = np.asarray(train_fps, dtype=float)
    train_norm = train_matrix.sum(axis=1)  # popcount per training fp
    out: list[dict[str, Any]] = []
    for rec in ligands:
        mid = int(rec.id or 0)
        query = fp_map.get(mid)
        if query is None:
            continue
        query = np.asarray(query, dtype=float)
        inter = train_matrix @ query  # |A ∩ B| per training fp
        union = train_norm + query.sum() - inter
        tanimoto = np.divide(inter, union, out=np.zeros_like(inter), where=union > 0)
        top = np.sort(tanimoto)[::-1][: max(1, int(k))]
        sim = float(np.mean(top))
        out.append({"molecule_id": mid, "name": names.get(mid, ""),
                    "similarity": sim, "in_domain": bool(sim >= float(threshold))})
    return out

correlation_matrix

correlation_matrix(*, feature_source: str = 'descriptors', molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, corr_threshold: float = 0.95) -> dict[str, Any]

Pearson correlation matrix across the descriptor block's features (over the project's ligands) — the redundancy the training pipeline prunes. feature_source 'descriptors' (14) or 'rdkit2d' (~200); ecfp4 is unsupported (2048 binary bits). Returns {labels, matrix, dropped} where dropped = features the |r|>corr_threshold filter would remove (kept feature listed first).

Source code in src/amdockvs/qsar/api.py
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
def correlation_matrix(
    self,
    *,
    feature_source: str = "descriptors",
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    corr_threshold: float = 0.95,
) -> dict[str, Any]:
    """Pearson correlation matrix across the descriptor block's features (over the project's
    ligands) — the redundancy the training pipeline prunes. feature_source 'descriptors' (14) or
    'rdkit2d' (~200); ecfp4 is unsupported (2048 binary bits). Returns {labels, matrix, dropped}
    where dropped = features the |r|>corr_threshold filter would remove (kept feature listed first)."""
    self.runtime._require_active_project()
    source = str(feature_source).lower()
    if source in {"ecfp4", "fingerprint", "morgan"}:
        raise ValueError("Correlation heatmap is for descriptor blocks, not fingerprints.")
    if source in {"rdkit2d", "rdkit_2d", "rdkit-2d"}:
        feature_kind = "rdkit2d"
        from amdockvs.chemistry.descriptors import rdkit2d_descriptor_names

        features = tuple(rdkit2d_descriptor_names())
    else:
        feature_kind = "descriptors"
        features = normalize_feature_names(None)
    ligands = self._ligands(molecule_set)
    with self.runtime.workspace.project_db.get_session() as session:
        _ids, x_rows, names, _missing = self._design_matrix(
            session, ligands, feature_kind=feature_kind, features=features, fp_radius=2, fp_nbits=2048
        )
    if len(x_rows) < 2:
        raise ValueError("Need >=2 ligands with descriptors to compute correlations.")
    x_matrix = np.asarray(x_rows, dtype=float)
    # median-impute rdkit2d gaps, then drop zero-variance columns (correlation is undefined there)
    medians = np.nanmedian(x_matrix, axis=0)
    nan_idx = np.where(np.isnan(x_matrix))
    x_matrix[nan_idx] = np.take(medians, nan_idx[1])
    keep = x_matrix.var(axis=0) > 0
    x_matrix = x_matrix[:, keep]
    labels = [n for n, k in zip(names, keep) if k]
    corr = np.nan_to_num(np.corrcoef(x_matrix, rowvar=False), nan=0.0)
    from amdockvs.qsar.modeling import CorrelationFilter

    support = CorrelationFilter(threshold=float(corr_threshold)).fit(x_matrix).get_support()
    dropped = [n for n, kept in zip(labels, support) if not kept]
    return {"labels": labels, "matrix": corr.tolist(), "dropped": dropped, "n_ligands": int(x_matrix.shape[0])}

split_preview

split_preview(*, endpoint: str, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, split: str = 'scaffold', test_size: float = 0.3, class_threshold: float | None = None, seed: int = 0, bins: int = 10) -> dict[str, Any]

Train/test label distribution for an endpoint WITHOUT training, so you can see whether the split balances the labels — not just chemical space. Scaffold split keeps whole scaffolds together but does NOT stratify by class, so an imbalanced endpoint can land most actives on one side. Returns {task, categories, train[], test[], n_train, n_test, note} — counts per class (classification) or per value-bin (regression), aligned to categories.

Source code in src/amdockvs/qsar/api.py
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
def split_preview(
    self,
    *,
    endpoint: str,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    split: str = "scaffold",
    test_size: float = 0.3,
    class_threshold: float | None = None,
    seed: int = 0,
    bins: int = 10,
) -> dict[str, Any]:
    """Train/test label distribution for an endpoint WITHOUT training, so you can see whether the
    split balances the labels — not just chemical space. Scaffold split keeps whole scaffolds
    together but does NOT stratify by class, so an imbalanced endpoint can land most actives on
    one side. Returns {task, categories, train[], test[], n_train, n_test, note} — counts per
    class (classification) or per value-bin (regression), aligned to `categories`."""
    self.runtime._require_active_project()
    from amdockvs.qsar.modeling import grouped_holdout_split

    endpoint_text = str(endpoint or "").strip()
    if not endpoint_text:
        raise ValueError("split_preview requires endpoint.")
    ligands = self._ligands(molecule_set)
    with self.runtime.workspace.project_db.get_session() as session:
        activity_by_ligand = self._latest_activity_by_ligand(session, endpoint_text)
        labeled = [r for r in ligands if int(r.id or 0) in activity_by_ligand]
        used_ids = [int(r.id or 0) for r in labeled]
        scaffolds = self._scaffold_keys(labeled) if split == "scaffold" else {}
    if len(used_ids) < 2:
        raise ValueError(f"Only {len(used_ids)} labeled ligand(s) for '{endpoint_text}'.")
    y = np.asarray([activity_by_ligand[mid] for mid in used_ids], dtype=float)
    if split == "scaffold":
        train_idx, test_idx = grouped_holdout_split(
            [scaffolds.get(mid, "") for mid in used_ids], test_size=float(test_size), seed=int(seed)
        )
    else:
        from sklearn.model_selection import train_test_split

        train_idx, test_idx = train_test_split(
            list(range(len(used_ids))), test_size=float(test_size), random_state=int(seed)
        )
    kind = self.endpoint_kinds().get(endpoint_text, "")
    is_class = kind == "categorical" or class_threshold is not None or set(np.unique(y).tolist()) <= {0.0, 1.0}
    if is_class:
        yb = (y >= float(class_threshold)).astype(int) if class_threshold is not None else y.astype(int)
        classes = sorted(set(yb.tolist()))
        train = [int((yb[train_idx] == c).sum()) for c in classes]
        test = [int((yb[test_idx] == c).sum()) for c in classes]
        # active-class fraction on each side — the imbalance the split may distort
        pos = classes[-1] if classes else 1
        fr = lambda idx: float((yb[idx] == pos).mean()) if len(idx) else 0.0
        note = f"class {pos} fraction — train {fr(train_idx):.3f} vs test {fr(test_idx):.3f}"
        return {"task": "classification", "categories": [str(c) for c in classes],
                "train": train, "test": test, "n_train": len(train_idx), "n_test": len(test_idx), "note": note}
    lo, hi = float(y.min()), float(y.max())
    edges = np.linspace(lo, hi, int(bins) + 1) if hi > lo else np.array([lo, lo + 1.0])
    cats = [f"{edges[i]:.2g}" for i in range(len(edges) - 1)]
    train = [int(v) for v in np.histogram(y[train_idx], bins=edges)[0]]
    test = [int(v) for v in np.histogram(y[test_idx], bins=edges)[0]]
    note = f"mean value — train {float(y[train_idx].mean()):.3f} vs test {float(y[test_idx].mean()):.3f}"
    return {"task": "regression", "categories": cats, "train": train, "test": test,
            "n_train": len(train_idx), "n_test": len(test_idx), "note": note}

train

train(*, endpoint: str, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, algorithm: str = 'linear_regression', task: str = 'regression', feature_names: Sequence[str] | None = None, feature_source: str = 'descriptors', fp_radius: int = 2, fp_nbits: int = 2048, name: str = '', test_size: float = 0.0, split: str = 'random', cv_folds: int = 0, class_threshold: float | None = None, corr_threshold: float = 0.95, y_scramble: int = 0, hyperparams: dict[str, Any] | None = None, seed: int = 0) -> QSARModelRecord

Fit a model template from the ligands of molecule_set that have an activity for endpoint. feature_source: 'descriptors' (14 physchem columns), 'rdkit2d' (full ~200 RDKit block, computed+cached on first use) or 'ecfp4' (stored Morgan fingerprint). For descriptor sources the preprocessing (impute→drop zero-variance→drop |r|>corr_threshold→ scale) lives inside the CV pipeline, so scores are leakage-free. split 'scaffold' holds out whole Bemis-Murcko scaffolds. cv_folds>=2 adds a CV score (Q²/MCC). class_threshold binarizes a continuous endpoint (active = value >= threshold); leave None for categorical endpoints. y_scramble>=1 runs that many label-permutation refits (should score ~0; else signal is spurious).

Source code in src/amdockvs/qsar/api.py
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
def train(
    self,
    *,
    endpoint: str,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    algorithm: str = "linear_regression",
    task: str = "regression",
    feature_names: Sequence[str] | None = None,
    feature_source: str = "descriptors",
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    name: str = "",
    test_size: float = 0.0,
    split: str = "random",
    cv_folds: int = 0,
    class_threshold: float | None = None,
    corr_threshold: float = 0.95,
    y_scramble: int = 0,
    hyperparams: dict[str, Any] | None = None,
    seed: int = 0,
) -> QSARModelRecord:
    """Fit a model template from the ligands of ``molecule_set`` that have an activity for
    ``endpoint``. feature_source: 'descriptors' (14 physchem columns), 'rdkit2d' (full ~200
    RDKit block, computed+cached on first use) or 'ecfp4' (stored Morgan fingerprint). For
    descriptor sources the preprocessing (impute→drop zero-variance→drop |r|>corr_threshold→
    scale) lives inside the CV pipeline, so scores are leakage-free. split 'scaffold' holds out
    whole Bemis-Murcko scaffolds. cv_folds>=2 adds a CV score (Q²/MCC). class_threshold binarizes
    a *continuous* endpoint (active = value >= threshold); leave None for categorical endpoints.
    y_scramble>=1 runs that many label-permutation refits (should score ~0; else signal is spurious)."""
    self.runtime._require_active_project()
    endpoint_text = str(endpoint or "").strip()
    if not endpoint_text:
        raise ValueError("train requires endpoint.")
    source = str(feature_source).lower()
    if source in {"ecfp4", "fingerprint", "morgan"}:
        feature_kind = "ecfp4"
    elif source in {"rdkit2d", "rdkit_2d", "rdkit-2d"}:
        feature_kind = "rdkit2d"
    else:
        feature_kind = "descriptors"
    if feature_kind == "descriptors":
        features = normalize_feature_names(feature_names)
    elif feature_kind == "rdkit2d":
        from amdockvs.chemistry.descriptors import rdkit2d_descriptor_names

        features = tuple(rdkit2d_descriptor_names())
    else:
        features = ()
    ligands = self._ligands(molecule_set)
    with self.runtime.workspace.project_db.get_session() as session:
        activity_by_ligand = self._latest_activity_by_ligand(session, endpoint_text)
        labeled = [r for r in ligands if int(r.id or 0) in activity_by_ligand]
        missing_act = len(ligands) - len(labeled)
        used_ids, x_rows, out_names, missing_desc = self._design_matrix(
            session, labeled, feature_kind=feature_kind, features=features, fp_radius=fp_radius, fp_nbits=fp_nbits
        )
        scaffolds = self._scaffold_keys(labeled) if split == "scaffold" else {}
    y_rows = [activity_by_ligand[mid] for mid in used_ids]
    if len(x_rows) < 2:
        raise ValueError(
            f"train needs >=2 usable ligands; got {len(x_rows)} "
            f"(missing_descriptor={missing_desc}, missing_activity={missing_act})."
        )

    x_matrix = np.asarray(x_rows, dtype=float)
    y_raw = np.asarray(y_rows, dtype=float)
    if task == "classification":
        y_vector = (y_raw >= float(class_threshold)).astype(int) if class_threshold is not None else y_raw.astype(int)
    else:
        y_vector = y_raw

    if task == "classification" and np.unique(y_vector).size < 2:
        raise ValueError(
            "All ligands fall in a single class after labeling — a model can't be trained. "
            "For a categorical endpoint don't set a class threshold (the value is already the "
            "label); for a continuous one, pick a threshold that leaves both classes populated."
        )

    x_train, y_train, holdout = x_matrix, y_vector, None
    test_ids: set[int] = set()  # for split-membership reporting (train/test; no separate val set)
    if test_size and 0.0 < float(test_size) < 1.0 and x_matrix.shape[0] >= 4:
        if split == "scaffold":
            from amdockvs.qsar.modeling import grouped_holdout_split

            train_idx, test_idx = grouped_holdout_split(
                [scaffolds.get(mid, "") for mid in used_ids], test_size=float(test_size), seed=int(seed)
            )
            if test_idx and train_idx:
                x_train, y_train = x_matrix[train_idx], y_vector[train_idx]
                holdout = (x_matrix[test_idx], y_vector[test_idx])
                test_ids = {used_ids[i] for i in test_idx}
        else:
            from sklearn.model_selection import train_test_split

            indices = np.arange(x_matrix.shape[0])
            train_i, test_i = train_test_split(indices, test_size=float(test_size), random_state=int(seed))
            x_train, y_train = x_matrix[train_i], y_vector[train_i]
            holdout = (x_matrix[test_i], y_vector[test_i])
            test_ids = {used_ids[i] for i in test_i}

    fitted = fit_model(
        x_train, y_train, feature_names=out_names, algorithm=algorithm, task=task, hyperparams=hyperparams,
        feature_kind=feature_kind, corr_threshold=float(corr_threshold),
        fp_radius=fp_radius if feature_kind == "ecfp4" else None,
        fp_nbits=fp_nbits if feature_kind == "ecfp4" else None,
    )
    feature_summary = {
        "descriptors": list(out_names),
        "rdkit2d": f"rdkit2d:{len(out_names)}",
        "ecfp4": f"ecfp4:{fp_nbits}",
    }[feature_kind]
    metrics: dict[str, Any] = {
        "task": task,
        "algorithm": algorithm,
        "feature_kind": feature_kind,
        "features": feature_summary,
        "split": split,
        "class_threshold": class_threshold,
        "corr_threshold": float(corr_threshold),
        "n_train": int(x_train.shape[0]),
        "train": self._score(task, y_train, fitted, x_train),
        "usable_ligands": used_ids,
        "split_assignment": [[mid, "test" if mid in test_ids else "train"] for mid in used_ids],
    }
    if holdout is not None:
        metrics["test"] = self._score(task, holdout[1], fitted, holdout[0])
    if cv_folds and int(cv_folds) >= 2:
        from amdockvs.qsar.modeling import cross_val_score_mean

        metrics["cv_folds"] = int(cv_folds)
        cv_score = cross_val_score_mean(
            x_matrix, y_vector, algorithm=algorithm, task=task, cv=int(cv_folds),
            hyperparams=hyperparams, feature_kind=feature_kind, corr_threshold=float(corr_threshold),
        )
        metrics["q2" if task == "regression" else "cv_mcc"] = cv_score
    if y_scramble and int(y_scramble) >= 1:
        from amdockvs.qsar.modeling import y_scramble_score

        metrics["y_scramble"] = y_scramble_score(
            x_matrix, y_vector, algorithm=algorithm, task=task, n_permutations=int(y_scramble),
            hyperparams=hyperparams, feature_kind=feature_kind, corr_threshold=float(corr_threshold), seed=int(seed),
        )
    importance = feature_importance(fitted.estimator, out_names)
    if importance:
        metrics["feature_importance"] = [[n, v] for n, v in importance]

    with self.runtime.workspace.project_db.get_session() as session:
        model_record = QSARModelRecord(
            name=str(name or "").strip() or f"{algorithm}_{endpoint_text}",
            algorithm=algorithm,
            target=endpoint_text,
            feature_type="descriptors_2d",
            source="trained",
            metrics=metrics,
        )
        session.add(model_record)
        session.flush()
        session.refresh(model_record)
        artifact = self._artifact_path(f"model_{int(model_record.id or 0)}.joblib")
        save_model(artifact, fitted)
        model_record.model_path = str(artifact.relative_to(self._project_root()))
        session.add(model_record)
        session.commit()
        session.refresh(model_record)
        return model_record

load_external_model

load_external_model(*, path: PathLike, name: str, endpoint: str = '') -> QSARModelRecord

Register a pre-built model artifact (a joblib FittedModel) for prediction. No training input needed — the artifact already carries features + task.

Source code in src/amdockvs/qsar/api.py
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
def load_external_model(
    self,
    *,
    path: PathLike,
    name: str,
    endpoint: str = "",
) -> QSARModelRecord:
    """Register a pre-built model artifact (a joblib FittedModel) for prediction. No
    training input needed — the artifact already carries features + task."""
    self.runtime._require_active_project()
    source_path = Path(path).expanduser().resolve()
    fitted = load_model(source_path)  # validates format
    with self.runtime.workspace.project_db.get_session() as session:
        model_record = QSARModelRecord(
            name=str(name or source_path.stem),
            algorithm=fitted.algorithm,
            target=str(endpoint or ""),
            feature_type="descriptors_2d",
            source="external",
            metrics={"task": fitted.task, "features": list(fitted.feature_names)},
        )
        session.add(model_record)
        session.flush()
        session.refresh(model_record)
        artifact = self._artifact_path(f"model_{int(model_record.id or 0)}.joblib")
        save_model(artifact, fitted)
        model_record.model_path = str(artifact.relative_to(self._project_root()))
        session.add(model_record)
        session.commit()
        session.refresh(model_record)
        return model_record

roc_curve_points

roc_curve_points(*, model: QSARModelRef | int, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, endpoint: str | None = None, subset: str = 'all') -> dict[str, Any]

(fpr, tpr) ROC points + AUC for a binary classification model. subset 'test' (held-out — the honest predictivity), 'train' (optimistic; the gap to test shows overfitting) or 'all'. Classification-only; needs a probabilistic model and both classes present.

Source code in src/amdockvs/qsar/api.py
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
def roc_curve_points(
    self,
    *,
    model: QSARModelRef | int,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    endpoint: str | None = None,
    subset: str = "all",
) -> dict[str, Any]:
    """(fpr, tpr) ROC points + AUC for a binary classification model. subset 'test' (held-out —
    the honest predictivity), 'train' (optimistic; the gap to test shows overfitting) or 'all'.
    Classification-only; needs a probabilistic model and both classes present."""
    self.runtime._require_active_project()
    ligands = self._ligands(molecule_set)
    with self.runtime.workspace.project_db.get_session() as session:
        record, fitted = self._load_fitted(session, model)
        if fitted.task != "classification":
            raise ValueError("ROC curve is only for classification models.")
        endpoint_text = str(endpoint or record.target or "").strip()
        activity_by_ligand = self._latest_activity_by_ligand(session, endpoint_text)
        threshold = (record.metrics or {}).get("class_threshold")
        labeled = [r for r in ligands if int(r.id or 0) in activity_by_ligand]
        labeled = self._filter_by_subset(labeled, record, subset)
        used_ids, x_rows, _n, _m = self._design_matrix(
            session, labeled, feature_kind=fitted.feature_kind, features=self._features_for(fitted),
            fp_radius=int(fitted.fp_radius or 2), fp_nbits=int(fitted.fp_nbits or 2048),
        )
    if not x_rows:
        raise ValueError(f"No labeled, feature-complete ligands in the '{subset}' subset for a ROC curve.")
    x_matrix = np.asarray(x_rows, dtype=float)
    y_raw = np.asarray([activity_by_ligand[mid] for mid in used_ids], dtype=float)
    y_true = (y_raw >= float(threshold)).astype(int) if threshold is not None else y_raw.astype(int)
    # A stale/0.0 threshold on a categorical 0/1 endpoint collapses every label to one class —
    # fall back to the raw labels (the endpoint is already categorical) so old models still plot.
    if threshold is not None and len(set(y_true.tolist())) != 2:
        y_true = y_raw.astype(int)
    estimator = fitted.estimator
    if not hasattr(estimator, "predict_proba") or len(set(y_true.tolist())) != 2:
        raise ValueError("ROC needs a probabilistic binary model with both classes labeled.")
    from sklearn.metrics import roc_auc_score, roc_curve

    proba = np.asarray(estimator.predict_proba(x_matrix))
    if proba.ndim != 2 or proba.shape[1] < 2:
        raise ValueError("model was trained on a single class — retrain it (no ROC possible).")
    scores = proba[:, 1]
    fpr, tpr, _thr = roc_curve(y_true, scores)
    return {
        "endpoint": endpoint_text,
        "subset": str(subset or "all").lower(),
        "points": [(float(a), float(b)) for a, b in zip(fpr, tpr)],
        "auc": float(roc_auc_score(y_true, scores)),
        "n": int(len(y_true)),
    }

prediction_rows

prediction_rows(*, model: QSARModelRef | int) -> list[dict[str, Any]]

Predictions joined with the ligand name, for the Predictions table: [{molecule_id, name, value, confidence}] ordered by ligand id. Excluded ligands are left out, as in every other ligand listing.

Source code in src/amdockvs/qsar/api.py
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
def prediction_rows(self, *, model: QSARModelRef | int) -> list[dict[str, Any]]:
    """Predictions joined with the ligand name, for the Predictions table:
    [{molecule_id, name, value, confidence}] ordered by ligand id. Excluded ligands are left
    out, as in every other ligand listing."""
    self.runtime._require_active_project()
    model_id = int(model.id if hasattr(model, "id") else model)
    with self.runtime.workspace.project_db.get_session() as session:
        rows = list(session.exec(
            select(QSARPredictionRecord)
            .where(QSARPredictionRecord.model_id == model_id)
            .order_by(QSARPredictionRecord.molecule_id)
        ))
        names = {
            int(m.id): str(m.name or "")
            for m in session.exec(
                select(MoleculeRecord)
                .where(MoleculeRecord.id.in_([int(r.molecule_id) for r in rows] or [0]))
                .where(MoleculeRecord.excluded == False)  # noqa: E712
            )
        }
    return [
        {"molecule_id": int(r.molecule_id), "name": names[int(r.molecule_id)],
         "value": float(r.value), "confidence": None if r.confidence is None else float(r.confidence)}
        for r in rows if int(r.molecule_id) in names
    ]

cut_predictions

cut_predictions(*, model: QSARModelRef | int, top_n: int | None = None, cutoff: float | None = None, higher_is_better: bool = True, exclude_rest: bool = False, ids_limit: int = 0) -> dict[str, Any]

Use a model's predictions as a filter: keep the ligands at or beyond cutoff (>= when higher is better, <= otherwise) and, of those, at most the top_n best (ties break by id). With exclude_rest every other ligand the model scored is marked excluded (reversible); ligands it could not score, and ligands already excluded, are left alone.

The cut is a subquery over the predictions, so nothing here grows with the library: the exclusion is one UPDATE and only ids_limit ids ever come back. Returns {"kept": n, "rest": n, "excluded": how many were marked, "ids": the first ids_limit kept, best first}.

Source code in src/amdockvs/qsar/api.py
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
def cut_predictions(
    self,
    *,
    model: QSARModelRef | int,
    top_n: int | None = None,
    cutoff: float | None = None,
    higher_is_better: bool = True,
    exclude_rest: bool = False,
    ids_limit: int = 0,
) -> dict[str, Any]:
    """Use a model's predictions as a filter: keep the ligands at or beyond ``cutoff`` (>= when
    higher is better, <= otherwise) and, of those, at most the ``top_n`` best (ties break by
    id). With ``exclude_rest`` every other ligand the model scored is marked excluded
    (reversible); ligands it could not score, and ligands already excluded, are left alone.

    The cut is a subquery over the predictions, so nothing here grows with the library: the
    exclusion is one UPDATE and only ``ids_limit`` ids ever come back. Returns {"kept": n,
    "rest": n, "excluded": how many were marked, "ids": the first ``ids_limit`` kept, best first}."""
    if top_n is None and cutoff is None:
        raise ValueError("cut_predictions needs top_n and/or cutoff.")
    self.runtime._require_active_project()
    model_id = int(model.id if hasattr(model, "id") else model)
    scored_ligand = aliased(MoleculeRecord)  # aliased: the UPDATE below targets the table itself
    scored = (
        select(QSARPredictionRecord.molecule_id)
        .join(scored_ligand, scored_ligand.id == QSARPredictionRecord.molecule_id)
        .where(QSARPredictionRecord.model_id == model_id, scored_ligand.excluded == False)  # noqa: E712
    )
    value = QSARPredictionRecord.value
    kept = scored.order_by(value.desc() if higher_is_better else value.asc(), QSARPredictionRecord.molecule_id)
    if cutoff is not None:
        kept = kept.where(value >= float(cutoff) if higher_is_better else value <= float(cutoff))
    if top_n is not None:
        kept = kept.limit(max(0, int(top_n)))
    how = (f"top {int(top_n)}" if top_n is not None else "") + (
        f"{' ' if top_n is not None else ''}{'>=' if higher_is_better else '<='} {float(cutoff):g}"
        if cutoff is not None else "")
    with self.runtime.workspace.project_db.get_session() as session:
        n_scored = int(session.exec(select(func.count()).select_from(scored.subquery())).one())
        n_kept = int(session.exec(select(func.count()).select_from(kept.subquery())).one())
        ids = [int(i) for i in session.exec(kept.limit(min(int(ids_limit), n_kept)))] if ids_limit else []
        excluded = 0
        if exclude_rest and n_kept < n_scored:
            result = session.exec(
                sql_update(MoleculeRecord)
                .where(MoleculeRecord.id.in_(scored), MoleculeRecord.id.not_in(kept))
                .values(excluded=True, exclusion_reason=f"qsar: outside the cut of model #{model_id} ({how})")
            )
            excluded = int(getattr(result, "rowcount", 0) or 0)
            session.commit()
    return {"kept": n_kept, "rest": n_scored - n_kept, "excluded": excluded, "ids": ids}

evaluate

evaluate(*, model: QSARModelRef | int, molecule_set: MoleculeSetRef | MoleculeScope | int | None = None, endpoint: str | None = None) -> dict[str, Any]

Score a labeled molecule set against a model and return task-appropriate metrics.

Source code in src/amdockvs/qsar/api.py
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
def evaluate(
    self,
    *,
    model: QSARModelRef | int,
    molecule_set: MoleculeSetRef | MoleculeScope | int | None = None,
    endpoint: str | None = None,
) -> dict[str, Any]:
    """Score a labeled molecule set against a model and return task-appropriate metrics."""
    self.runtime._require_active_project()
    ligands = self._ligands(molecule_set)
    with self.runtime.workspace.project_db.get_session() as session:
        record, fitted = self._load_fitted(session, model)
        endpoint_text = str(endpoint or record.target or "").strip()
        if not endpoint_text:
            raise ValueError("evaluate needs an endpoint (model has no target).")
        activity_by_ligand = self._latest_activity_by_ligand(session, endpoint_text)
        features = self._features_for(fitted)
        threshold = (record.metrics or {}).get("class_threshold")
        labeled = [r for r in ligands if int(r.id or 0) in activity_by_ligand]
        used_ids, x_rows, _names, _missing = self._design_matrix(
            session, labeled, feature_kind=fitted.feature_kind, features=features,
            fp_radius=int(fitted.fp_radius or 2), fp_nbits=int(fitted.fp_nbits or 2048),
        )
        if not x_rows:
            raise ValueError("No labeled, descriptor-complete ligands to evaluate.")
        x_matrix = np.asarray(x_rows, dtype=float)
        y_raw = np.asarray([activity_by_ligand[mid] for mid in used_ids], dtype=float)
        if fitted.task == "classification":
            y_eval = (y_raw >= float(threshold)).astype(int) if threshold is not None else y_raw.astype(int)
            metrics = self._score("classification", y_eval, fitted, x_matrix)
        else:
            metrics = self._score("regression", y_raw, fitted, x_matrix)
        metrics["model_id"] = int(record.id or 0)
        metrics["endpoint"] = endpoint_text
        return metrics

model_subsets

model_subsets(*, model: QSARModelRef | int) -> dict[int, str]

{ligand_id: 'train'|'test'} for a trained model's split (empty if not recorded). The lean pipeline has no separate validation set — only train/test.

Source code in src/amdockvs/qsar/api.py
1421
1422
1423
1424
1425
1426
1427
1428
1429
def model_subsets(self, *, model: QSARModelRef | int) -> dict[int, str]:
    """{ligand_id: 'train'|'test'} for a trained model's split (empty if not recorded).
    The lean pipeline has no separate validation set — only train/test."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        record = session.get(QSARModelRecord, int(model.id if hasattr(model, "id") else model))
        if record is None:
            return {}
        return {int(mid): str(sub) for mid, sub in (record.metrics or {}).get("split_assignment", [])}

atom_contributions

atom_contributions(*, model: QSARModelRef | int, ligand_id: int) -> dict[str, Any]

Per-atom contribution to an ECFP4 model's prediction (StarDrop's "glowing molecule"). Returns {molblock, weights[per atom], prediction}. ECFP4 models only.

Source code in src/amdockvs/qsar/api.py
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
def atom_contributions(self, *, model: QSARModelRef | int, ligand_id: int) -> dict[str, Any]:
    """Per-atom contribution to an ECFP4 model's prediction (StarDrop's "glowing molecule").
    Returns {molblock, weights[per atom], prediction}. ECFP4 models only."""
    self.runtime._require_active_project()
    from rdkit import Chem, DataStructs
    from rdkit.Chem.Draw import SimilarityMaps

    with self.runtime.workspace.project_db.get_session() as session:
        record, fitted = self._load_fitted(session, model)
        if fitted.feature_kind != "ecfp4":
            raise ValueError("Glowing molecule needs an ECFP4 (fingerprint) model.")
        rec = session.get(MoleculeRecord, int(ligand_id))
        if rec is None:
            raise ValueError(f"Unknown ligand id: {ligand_id}")
        mol = self._ligand_mol(session, rec)
    if mol is None:
        raise ValueError("No structure available for this ligand.")
    radius, nbits = int(fitted.fp_radius or 2), int(fitted.fp_nbits or 2048)

    def fp_fn(probe, atom_id=-1):
        return SimilarityMaps.GetMorganFingerprint(probe, atomId=atom_id, radius=radius, nBits=nbits)

    # A classifier's label does not move when one atom is removed, so every weight would be
    # 0: follow the probability instead — P(active) when binary, else P(predicted class).
    proba = getattr(fitted.estimator, "predict_proba", None) if fitted.task == "classification" else None
    column = -1

    def row(fp):
        arr = np.zeros((nbits,), dtype=np.float64)
        DataStructs.ConvertToNumpyArray(fp, arr)
        return arr.reshape(1, -1)

    def pred_fn(fp):
        if proba is None:
            return float(fitted.predict(row(fp))[0])
        return float(proba(row(fp))[0][column])

    if proba is not None and len(fitted.classes) > 2:
        column = int(np.argmax(proba(row(fp_fn(mol)))[0]))
    weights = SimilarityMaps.GetAtomicWeightsForModel(mol, fp_fn, pred_fn)
    return {
        "molblock": Chem.MolToMolBlock(mol),
        "weights": [float(w) for w in weights],
        "prediction": pred_fn(fp_fn(mol)),
    }

Binding sites

BindingSiteAPI dataclass

BindingSiteAPI(runtime: Any)

Methods:

Name Description
select

Build a lazy binding-site selection.

delete_sites

Deletes sites by id. The counterpart of prediction being additive.

save_site

Saves a site. Without binding_site_id it creates a new one; with it, rewrites that one.

suggest_box_from_ligand

Auto box from a reference ligand: center = its centroid, size = cubic box

select

select(*, molecule_id: int | None = None, source: str | None = None, run_id: str | None = None, limit: int | None = None) -> Selection[BindingSite]

Build a lazy binding-site selection.

Source code in src/amdockvs/binding_sites/api.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def select(
    self,
    *,
    molecule_id: int | None = None,
    source: str | None = None,
    run_id: str | None = None,
    limit: int | None = None,
) -> Selection[BindingSite]:
    """Build a lazy binding-site selection."""
    self.runtime._require_active_project()
    return self._selection(BindingSiteScope(
        molecule_id=None if molecule_id is None else int(molecule_id),
        source=str(source) if source is not None else None,
        run_id=str(run_id) if run_id is not None else None,
        limit=None if limit is None else max(0, int(limit)),
    ))

delete_sites

delete_sites(binding_site_ids: Iterable[int]) -> int

Deletes sites by id. The counterpart of prediction being additive.

Source code in src/amdockvs/binding_sites/api.py
271
272
273
274
275
276
def delete_sites(self, binding_site_ids: Iterable[int]) -> int:
    """Deletes sites by id. The counterpart of prediction being additive."""
    self.runtime._require_active_project()
    from amdockvs.project.deletion import delete_binding_sites

    return delete_binding_sites(self.runtime.workspace.project_db, binding_site_ids)

save_site

save_site(*, molecule_id: int, name: str = '', source: str = 'manual', source_ref: str = '', center: tuple[float, float, float], size: tuple[float, float, float], binding_site_id: int | None = None, set_active: bool = False, extra_data: dict[str, Any] | None = None) -> BindingSite

Saves a site. Without binding_site_id it creates a new one; with it, rewrites that one.

Source code in src/amdockvs/binding_sites/api.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def save_site(
        self,
        *,
        molecule_id: int,
        name: str = "",
        source: str = "manual",
        source_ref: str = "",
        center: tuple[float, float, float],
        size: tuple[float, float, float],
        binding_site_id: int | None = None,
        set_active: bool = False,
        extra_data: dict[str, Any] | None = None,
) -> BindingSite:
    """Saves a site. Without `binding_site_id` it creates a new one; with it, rewrites that one."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        molecule = session.get(MoleculeRecord, int(molecule_id))
        if molecule is None:
            raise ValueError(f"Molecule {molecule_id} does not exist.")
        site = None if binding_site_id is None else session.get(BindingSite, int(binding_site_id))
        if site is None:
            site = BindingSite(molecule_id=int(molecule_id))
        elif int(site.molecule_id) != int(molecule_id):
            raise ValueError(
                f"Binding site {binding_site_id} belongs to molecule {site.molecule_id}."
            )
        site.name = str(name or "")
        site.source = str(source or "manual")
        site.source_ref = str(source_ref or "")
        site.center_x = float(center[0])
        site.center_y = float(center[1])
        site.center_z = float(center[2])
        site.size_x = float(size[0])
        site.size_y = float(size[1])
        site.size_z = float(size[2])
        site.extra_data = dict(extra_data or {})
        session.add(site)
        session.flush()
        if set_active:
            molecule.active_binding_site_id = int(site.id or 0) or None
            session.add(molecule)
        session.commit()
        session.refresh(site)
        return site

suggest_box_from_ligand

suggest_box_from_ligand(*, ligand_id: int, scale: float | None = None, padding: float | None = None) -> dict[str, Any]

Auto box from a reference ligand: center = its centroid, size = cubic box derived from the ligand's radius of gyration. Returns {center, size, rg}.

scale/padding default to the configured geometry (settings > binding_sites).

Source code in src/amdockvs/binding_sites/api.py
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
def suggest_box_from_ligand(
        self,
        *,
        ligand_id: int,
        scale: float | None = None,
        padding: float | None = None,
) -> dict[str, Any]:
    """Auto box from a reference ligand: center = its centroid, size = cubic box
    derived from the ligand's radius of gyration. Returns {center, size, rg}.

    `scale`/`padding` default to the configured geometry (settings > binding_sites)."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        ligand = session.get(MoleculeRecord, int(ligand_id))
        if ligand is None:
            raise ValueError(f"Molecule {ligand_id} does not exist.")
        path = preferred_molecule_path(ligand)
    if path is None or not Path(path).exists():
        raise ValueError(f"Ligand {ligand_id} has no readable structure file.")
    coords = _read_atom_coords(Path(path))
    if not coords:
        raise ValueError(f"No atom coordinates found in {Path(path).name}.")
    geometry = app_config(self.runtime).binding_sites
    return box_from_coords(
        coords,
        scale=geometry.box_scale if scale is None else float(scale),
        padding=geometry.box_padding if padding is None else float(padding),
        minimum=geometry.box_min_edge,
        maximum=geometry.box_max_edge,
    )

Docking

DockingAPI dataclass

DockingAPI(runtime: Any)

Methods:

Name Description
live_campaign_progress

Current per-receptor totals of what is written.

recoverable_ranked_runs

Interrupted top-N campaigns recoverable from completed result shards.

select_hits

Select a recomputable Top-N from the durable HTP result dataset.

recover_ranked_hits

Materialize the best available N without submitting docking work.

preparation_summary

Return total/ready/failed preparation counts for a molecule scope.

unprepared_molecules_clause

Opaque SmartTable clause selecting molecules not ready for every engine family.

prepare_ligand_shards

prepare_ligands for a sharded library: the unit of work is a shard, not a row.

run_shards

run for a sharded library: the campaign, with the gate that makes it end.

list_box_residues

Residues with at least one atom inside the receptor's active binding-site box.

check_receptors

Receptor-only half of check_required: preparation + grid status, no ligand scan.

run

Run docking over the active library, selecting its row or shard adapter.

ligand_summaries

(best pose, pose count) per ligand, one page at a time.

compute_diagrams

Render 2D interaction diagrams for docking poses as a standalone job.

diagram_path

Convention path for a pose's diagram (may not exist yet).

live_campaign_progress

live_campaign_progress(*, job_ids: list[str] | tuple[str, ...] = ()) -> dict[int, dict[str, int]]

Current per-receptor totals of what is written.

Source code in src/amdockvs/docking/api.py
118
119
120
121
122
123
124
125
126
127
def live_campaign_progress(
    self,
    *,
    job_ids: list[str] | tuple[str, ...] = (),
) -> dict[int, dict[str, int]]:
    """Current per-receptor totals of what is written."""
    self.runtime._require_active_project()
    # ponytail: the engine does not list the chunks a job has in flight, so the totals count
    # what is written; `job_ids` comes back when it does.
    return live_target_progress(list_targets(self.runtime.workspace.project_db), [])

recoverable_ranked_runs

recoverable_ranked_runs() -> list[dict[str, Any]]

Interrupted top-N campaigns recoverable from completed result shards.

Source code in src/amdockvs/docking/api.py
129
130
131
132
133
134
135
def recoverable_ranked_runs(self) -> list[dict[str, Any]]:
    """Interrupted top-N campaigns recoverable from completed result shards."""
    context = self.runtime._require_active_project()
    return recoverable_ranked_runs(
        self.runtime.workspace.project_db,
        project_root=context.path,
    )

select_hits

select_hits(*, run_id: str, protocol_hash: str, filters: dict[str, Any] | None = None, top_n: int) -> list[dict[str, Any]]

Select a recomputable Top-N from the durable HTP result dataset.

Source code in src/amdockvs/docking/api.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def select_hits(
    self,
    *,
    run_id: str,
    protocol_hash: str,
    filters: dict[str, Any] | None = None,
    top_n: int,
) -> list[dict[str, Any]]:
    """Select a recomputable Top-N from the durable HTP result dataset."""
    context = self.runtime._require_active_project()
    return select_shard_hits(
        self.runtime.workspace.project_db,
        project_root=context.path,
        run_id=run_id,
        protocol_hash=protocol_hash,
        filters=filters,
        top_n=top_n,
    )

recover_ranked_hits

recover_ranked_hits(*, run_id: str, protocol_hash: str, top_n: int, threshold: float = 0.0) -> dict[str, int]

Materialize the best available N without submitting docking work.

Source code in src/amdockvs/docking/api.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def recover_ranked_hits(
    self,
    *,
    run_id: str,
    protocol_hash: str,
    top_n: int,
    threshold: float = 0.0,
) -> dict[str, int]:
    """Materialize the best available N without submitting docking work."""
    context = self.runtime._require_active_project()
    return recover_ranked_hits(
        self.runtime.workspace.project_db,
        project_root=context.path,
        run_id=run_id,
        protocol_hash=protocol_hash,
        top_n=top_n,
        threshold=threshold,
    )

preparation_summary

preparation_summary(scope: MoleculeScope, *, role_type: str, engine: str = 'ad4') -> dict[str, int]

Return total/ready/failed preparation counts for a molecule scope.

Source code in src/amdockvs/docking/api.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def preparation_summary(
        self,
        scope: MoleculeScope,
        *,
        role_type: str,
        engine: str = "ad4",
) -> dict[str, int]:
    """Return total/ready/failed preparation counts for a molecule scope."""
    self.runtime._require_active_project()
    normalized_role = str(role_type or "").strip().lower()
    normalized_engine = str(engine or "ad4").strip().lower()
    if normalized_role not in {"ligand", "receptor"}:
        raise ValueError("role_type must be 'ligand' or 'receptor'.")
    # Counted over the id stream in bound-parameter-sized chunks: a scope of a million ligands
    # produces three integers here, not a million-element list.
    ids = (int(value) for value in self.runtime.molecules.stream_ids(scope) if int(value) > 0)
    total = ready = failed = 0
    with self.runtime.workspace.project_db.get_session() as session:
        for chunk in batched(ids, 900):  # stay well under SQLite's bound-parameter limit
            total += len(chunk)
            rows = session.exec(
                select(EngineState.is_ready)
                .where(EngineState.molecule_id.in_(chunk))
                .where(EngineState.role_type == normalized_role)
                .where(EngineState.engine == normalized_engine)
            ).all()
            ready += sum(1 for value in rows if bool(value))
            failed += sum(1 for value in rows if not bool(value))
    return {"total": total, "ready": ready, "failed": failed}

unprepared_molecules_clause

unprepared_molecules_clause(*, role_type: str, engines: list[str] | tuple[str, ...])

Opaque SmartTable clause selecting molecules not ready for every engine family.

Source code in src/amdockvs/docking/api.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
def unprepared_molecules_clause(self, *, role_type: str, engines: list[str] | tuple[str, ...]):
    """Opaque SmartTable clause selecting molecules not ready for every engine family."""
    self.runtime._require_active_project()
    role = str(role_type or "").strip().lower()
    if role not in {"ligand", "receptor"}:
        raise ValueError("role_type must be 'ligand' or 'receptor'.")
    normalized_engines = tuple(
        dict.fromkeys(str(value or "").strip().lower() for value in engines if str(value or "").strip()))
    missing = [
        ~exists(
            select(EngineState.id).where(
                EngineState.molecule_id == MoleculeRecord.id,
                EngineState.role_type == role,
                EngineState.engine == engine,
                EngineState.is_ready.is_(True),
            )
        )
        for engine in normalized_engines
    ]
    if not missing:
        return None
    return missing[0] if len(missing) == 1 else or_(*missing)

prepare_ligand_shards

prepare_ligand_shards(*, program: str = key, force: bool = False, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None) -> str

prepare_ligands for a sharded library: the unit of work is a shard, not a row.

No scope argument on purpose — a sharded library has no rows to select from, so the whole inventory is the scope and narrowing it happened at import. One shard is one task.

Source code in src/amdockvs/docking/api.py
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def prepare_ligand_shards(
        self,
        *,
        program: str = VINA_PROGRAM.key,
        force: bool = False,
        executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
        depends_on: list[str] | None = None,
) -> str:
    """`prepare_ligands` for a sharded library: the unit of work is a shard, not a row.

    No scope argument on purpose — a sharded library has no rows to select from, so the
    whole inventory is the scope and narrowing it happened at import. One shard is one task.
    """
    self.runtime._require_active_project()
    program_spec = self.get_program_spec(program)
    if not program_spec.requires_ligand_preparation:
        raise ValueError(f"Docking program '{program_spec.key}' does not require ligand preparation.")
    if not program_spec.supports_shards:
        raise ValueError(f"Docking program '{program_spec.key}' does not support sharded libraries.")
    params = PrepareLigandShardsJobParams(
        engine=program_spec.preparation_engine,
        force=force,
    )
    return self.runtime.submit_job(
        prepare_ligand_shards_job,
        params=params.model_dump(mode="python"),
        executor_name=executor_name,
        depends_on=depends_on,
    )

run_shards

run_shards(*, program: str = key, receptor_set: MoleculeSetRef | MoleculeScope | int | None = None, hit_threshold: float, hit_cap: int, hit_mode: str = HIT_MODE_THRESHOLD, output_dir: PathLike | None = None, exhaustiveness: int = 8, num_modes: int = 9, box_center: tuple[float, float, float] | None = None, box_size: tuple[float, float, float] | None = None, scoring_function: str = 'vina', vina_backend: str = DEFAULT_VINA_BACKEND, vina_command: str = DEFAULT_VINA_COMMAND, vina_cpu: int = 1, seed: int = 0, spacing: float = 0.375, energy_range: float = 3.0, min_rmsd: float = 1.0, run_id: str | None = None, protocol_metadata: dict | None = None, engine_config: dict[str, Any] | None = None, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None, skip_existing: bool = True, payload: str = PAYLOAD_LIGHT, check_required: bool = True, selected_from_receptor_id: int | None = None, selected_top_n: int | None = None) -> str

run for a sharded library: the campaign, with the gate that makes it end.

No ligand scope — the library is the inventory of prepared shards. hit_threshold and hit_cap are required, not defaulted: a campaign without them writes back everything it scored, which is the failure mode htpvs exists to prevent. A dependent off-target stage sets selected_from_receptor_id; its deferred chunker reads that receptor's durable Top-N and extracts only those records from the source shards.

Source code in src/amdockvs/docking/api.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def run_shards(
        self,
        *,
        program: str = VINA_PROGRAM.key,
        receptor_set: MoleculeSetRef | MoleculeScope | int | None = None,
        hit_threshold: float,
        hit_cap: int,
        hit_mode: str = HIT_MODE_THRESHOLD,
        output_dir: PathLike | None = None,
        exhaustiveness: int = 8,
        num_modes: int = 9,
        box_center: tuple[float, float, float] | None = None,
        box_size: tuple[float, float, float] | None = None,
        scoring_function: str = "vina",
        vina_backend: str = DEFAULT_VINA_BACKEND,
        vina_command: str = DEFAULT_VINA_COMMAND,
        vina_cpu: int = 1,
        seed: int = 0,
        spacing: float = 0.375,
        energy_range: float = 3.0,
        min_rmsd: float = 1.0,
        run_id: str | None = None,
        protocol_metadata: dict | None = None,
        engine_config: dict[str, Any] | None = None,
        executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
        depends_on: list[str] | None = None,
        skip_existing: bool = True,
        payload: str = PAYLOAD_LIGHT,
        check_required: bool = True,
        selected_from_receptor_id: int | None = None,
        selected_top_n: int | None = None,
) -> str:
    """`run` for a sharded library: the campaign, with the gate that makes it end.

    No ligand scope — the library is the inventory of prepared shards. `hit_threshold` and
    `hit_cap` are required, not defaulted: a campaign without them writes back everything it
    scored, which is the failure mode `htpvs` exists to prevent. A dependent off-target
    stage sets `selected_from_receptor_id`; its deferred chunker reads that receptor's
    durable Top-N and extracts only those records from the source shards.
    """
    self.runtime._require_active_project()
    if selected_top_n is not None and selected_from_receptor_id is None:
        raise ValueError("selected_top_n requires selected_from_receptor_id.")
    if selected_from_receptor_id is not None:
        if str(hit_mode) != "top_n":
            raise ValueError("Off-target selection requires hit_mode='top_n'.")
        if not str(run_id or "").strip():
            raise ValueError("Off-target selection requires the reference run_id.")
    if (box_center is None) ^ (box_size is None):
        raise ValueError("docking.run requires both box_center and box_size when either one is provided.")
    program_spec = self.get_program_spec(program)
    if not program_spec.supports_shards:
        raise ValueError(f"Docking program '{program_spec.key}' does not support sharded libraries.")
    resolved_engine_config = _validated_engine_config(
        program_spec,
        engine_config,
        exhaustiveness=exhaustiveness,
        num_modes=num_modes,
        scoring_function=scoring_function,
        vina_backend=vina_backend,
        vina_command=vina_command,
        vina_cpu=vina_cpu,
        seed=seed,
        spacing=spacing,
        energy_range=energy_range,
        min_rmsd=min_rmsd,
    )
    receptor_set_ref = (
        None if receptor_set is None or is_molecule_scope(receptor_set)
        else ensure_molecule_set_ref(self.runtime, receptor_set, name="docking_receptor_input")
    )
    receptor_scope = scope_payload(receptor_set) if is_molecule_scope(receptor_set) else {}
    receptor_filters = self._program_scope_filters(receptor_scope, role="receptor", program=program_spec)
    params = DockShardsJobParams(
        output_dir=(
            str(Path(output_dir).expanduser().resolve()) if output_dir is not None
            else str(self.runtime.get_project_resource_path("docking_results"))
        ),
        engine=program_spec.docking_engine,
        preparation_engine=program_spec.preparation_engine,
        receptor_set_id=None if receptor_set_ref is None else int(receptor_set_ref.id),
        receptor_filters=receptor_filters,
        exhaustiveness=int(resolved_engine_config.get("exhaustiveness", exhaustiveness)),
        num_modes=int(resolved_engine_config.get("num_modes", num_modes)),
        box_center=box_center,
        box_size=box_size,
        scoring_function=str(resolved_engine_config.get("scoring_function", scoring_function)),
        vina_backend=str(resolved_engine_config.get("vina_backend", vina_backend)),
        vina_command=str(resolved_engine_config.get("vina_command", vina_command)),
        vina_cpu=int(resolved_engine_config.get("vina_cpu", vina_cpu)),
        seed=int(resolved_engine_config.get("seed", seed)),
        spacing=float(resolved_engine_config.get("spacing", spacing)),
        energy_range=float(resolved_engine_config.get("energy_range", energy_range)),
        min_rmsd=float(resolved_engine_config.get("min_rmsd", min_rmsd)),
        run_id=str(run_id or uuid4().hex),
        protocol_metadata=DockingProtocolMetadata.from_mapping(protocol_metadata or {}),
        engine_config=resolved_engine_config,
        hit_threshold=float(hit_threshold),
        hit_cap=int(hit_cap),
        hit_mode=str(hit_mode),
        selected_from_receptor_id=selected_from_receptor_id,
        selected_top_n=(int(selected_top_n) if selected_top_n is not None else int(hit_cap)),
        skip_existing=skip_existing,
        check_required=check_required,
    )
    return self.runtime.submit_job(
        dock_shards_job,
        params=params.model_dump(mode="python"),
        executor_name=executor_name,
        depends_on=depends_on,
        cpu_required=program_spec.resource_requirements(resolved_engine_config)["cpu_required"],
        # The gate lives in the parent, like the shard queue at import: it counts across
        # chunks and it writes molecules, neither of which a worker can do.
        result_handler_kwargs={
            "project_db": self.runtime.workspace.project_db,
            "hit_cap": int(hit_cap),
            "hit_threshold": float(hit_threshold),
            "hit_mode": str(hit_mode),
            "run_id": params.run_id,
            "engine": params.engine,
            "protocol_hash": str(params.protocol_metadata.hash or ""),
            "payload": str(payload),
            "selected_from_receptor_id": params.selected_from_receptor_id,
        },
    )

list_box_residues

list_box_residues(*, receptor_id: int, engine: str = 'vina') -> list[dict[str, Any]]

Residues with at least one atom inside the receptor's active binding-site box.

Candidate set for flexible-residue selection — short because the box filters it. Returns [] when there's no active grid or the structure file is unavailable.

Source code in src/amdockvs/docking/api.py
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
def list_box_residues(self, *, receptor_id: int, engine: str = "vina") -> list[dict[str, Any]]:
    """Residues with at least one atom inside the receptor's active binding-site box.

    Candidate set for flexible-residue selection — short because the box filters it.
    Returns [] when there's no active grid or the structure file is unavailable.
    """
    grid = self.get_grid(receptor_id=int(receptor_id), engine=engine)
    if grid is None:
        return []
    center = tuple(float(v) for v in grid.get("center") or [])
    size = tuple(float(v) for v in grid.get("size") or [])
    if len(center) != 3 or len(size) != 3:
        return []
    with self.runtime.workspace.project_db.get_session() as session:
        receptor = session.get(MoleculeRecord, int(receptor_id))
    if receptor is None:
        return []
    path = preferred_molecule_path(receptor)
    if path is None or not path.exists():
        return []
    try:
        with as_pdb(path) as readable_receptor:
            residues = residues_in_box(
                readable_receptor.read_text(encoding="utf-8", errors="ignore"), center, size
            )
    except (OSError, ValueError):
        return []
    return [
        {"key": r.key, "label": r.label, "chain": r.chain, "resname": r.resname, "resnum": r.resnum}
        for r in residues
    ]

check_receptors

check_receptors(*, program: str = key, receptor_set: MoleculeSetRef | MoleculeScope | int | None = None) -> dict[str, Any]

Receptor-only half of check_required: preparation + grid status, no ligand scan.

check_required materializes the full ligand set to report missing ligands; that's the expensive part. The Receptors step only shows receptor prep/grid status, so this skips ligands entirely (receptor sets are small).

Source code in src/amdockvs/docking/api.py
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
def check_receptors(
        self,
        *,
        program: str = VINA_PROGRAM.key,
        receptor_set: MoleculeSetRef | MoleculeScope | int | None = None,
) -> dict[str, Any]:
    """Receptor-only half of check_required: preparation + grid status, no ligand scan.

    check_required materializes the full ligand set to report missing ligands; that's
    the expensive part. The Receptors step only shows receptor prep/grid status, so this
    skips ligands entirely (receptor sets are small).
    """
    self.runtime._require_active_project()
    program_spec = self.get_program_spec(program)
    receptor_set_ref = (
        None
        if receptor_set is None or is_molecule_scope(receptor_set)
        else ensure_molecule_set_ref(self.runtime, receptor_set, name="docking_check_receptor_input")
    )
    receptor_scope = scope_payload(receptor_set) if is_molecule_scope(receptor_set) else {}
    receptor_filters = self._program_scope_filters(receptor_scope, role="receptor", program=program_spec)
    receptor_rows = list_entity_rows(
        self.runtime.workspace.project_db,
        entity_kind="receptor",
        engine=program_spec.preparation_engine,
        set_id=None if receptor_set_ref is None else int(receptor_set_ref.id),
        filters=receptor_filters,
        fields=("id", "prepared_engine", "grid_engine"),
        order=("id",),
    )
    receptor_ids = [int(row.get("id") or 0) for row in receptor_rows if int(row.get("id") or 0) > 0]
    ready_prepared = {
        int(row.get("id") or 0)
        for row in receptor_rows
        if not program_spec.requires_receptor_preparation or bool(row.get("prepared_engine"))
    }
    ready_grid = {
        int(row.get("id") or 0)
        for row in receptor_rows
        if not program_spec.requires_binding_site or bool(row.get("grid_engine"))
    }
    return {
        "receptor_ids": receptor_ids,
        "missing": {
            "receptors_prepared": [rid for rid in receptor_ids if rid not in ready_prepared],
            "receptor_binding_sites": [rid for rid in receptor_ids if rid not in ready_grid],
        },
        "counts": {
            "receptors_total": len(receptor_rows),
            "receptors_ready": len(ready_prepared),
            "receptor_grids_ready": len(ready_grid),
        },
    }

run

run(*, program: str = key, ligands: MoleculeSetRef | MoleculeScope | Selection[MoleculeRecord] | int | None = None, receptors: MoleculeSetRef | MoleculeScope | Selection[MoleculeRecord] | int | None = None, ligand_set: MoleculeSetRef | MoleculeScope | int | None = None, receptor_set: MoleculeSetRef | MoleculeScope | int | None = None, output_dir: PathLike | None = None, batch_size: int | None = None, exhaustiveness: int = 8, num_modes: int = 9, box_center: tuple[float, float, float] | None = None, box_size: tuple[float, float, float] | None = None, scoring_function: str = 'vina', vina_backend: str = DEFAULT_VINA_BACKEND, vina_command: str = DEFAULT_VINA_COMMAND, vina_cpu: int = 1, seed: int = 0, spacing: float = 0.375, energy_range: float = 3.0, min_rmsd: float = 1.0, run_id: str | None = None, protocol_metadata: dict | None = None, engine_config: dict[str, Any] | None = None, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None, check_required: bool = True, skip_existing: bool = True, compute_diagram: bool = False, diagram_format: str = 'png', hit_threshold: float | None = None, hit_cap: int = 0, hit_mode: str = HIT_MODE_THRESHOLD, payload: str = PAYLOAD_LIGHT) -> str

Run docking over the active library, selecting its row or shard adapter.

hit_threshold/hit_cap are optional for materialized rows and mandatory for a sharded library, where returning every scored molecule would defeat the storage model.

Source code in src/amdockvs/docking/api.py
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
def run(
        self,
        *,
        program: str = VINA_PROGRAM.key,
        ligands: MoleculeSetRef | MoleculeScope | Selection[MoleculeRecord] | int | None = None,
        receptors: MoleculeSetRef | MoleculeScope | Selection[MoleculeRecord] | int | None = None,
        ligand_set: MoleculeSetRef | MoleculeScope | int | None = None,
        receptor_set: MoleculeSetRef | MoleculeScope | int | None = None,
        output_dir: PathLike | None = None,
        batch_size: int | None = None,
        exhaustiveness: int = 8,
        num_modes: int = 9,
        box_center: tuple[float, float, float] | None = None,
        box_size: tuple[float, float, float] | None = None,
        scoring_function: str = "vina",
        vina_backend: str = DEFAULT_VINA_BACKEND,
        vina_command: str = DEFAULT_VINA_COMMAND,
        vina_cpu: int = 1,
        seed: int = 0,
        spacing: float = 0.375,
        energy_range: float = 3.0,
        min_rmsd: float = 1.0,
        run_id: str | None = None,
        protocol_metadata: dict | None = None,
        engine_config: dict[str, Any] | None = None,
        executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
        depends_on: list[str] | None = None,
        check_required: bool = True,
        skip_existing: bool = True,
        compute_diagram: bool = False,
        diagram_format: str = "png",
        hit_threshold: float | None = None,
        hit_cap: int = 0,
        hit_mode: str = HIT_MODE_THRESHOLD,
        payload: str = PAYLOAD_LIGHT,
) -> str:
    """Run docking over the active library, selecting its row or shard adapter.

    ``hit_threshold``/``hit_cap`` are optional for materialized rows and mandatory for a
    sharded library, where returning every scored molecule would defeat the storage model.
    """
    if ligands is not None:
        if ligand_set is not None:
            raise ValueError("Pass either ligands or ligand_set, not both.")
        ligand_set = ligands
    if receptors is not None:
        if receptor_set is not None:
            raise ValueError("Pass either receptors or receptor_set, not both.")
        receptor_set = receptors
    self.runtime._require_active_project()
    if ligand_set is None and has_shards(self.runtime.workspace.project_db):
        if hit_threshold is None or int(hit_cap) <= 0:
            raise ValueError(
                "docking.run over a sharded library requires hit_threshold and hit_cap."
            )
        if compute_diagram:
            raise ValueError(
                "compute_diagram is not available during sharded docking; compute diagrams "
                "after hits have been materialized."
            )
        return self.run_shards(
            program=program,
            receptor_set=receptor_set,
            hit_threshold=float(hit_threshold),
            hit_cap=int(hit_cap),
            hit_mode=hit_mode,
            output_dir=output_dir,
            exhaustiveness=exhaustiveness,
            num_modes=num_modes,
            box_center=box_center,
            box_size=box_size,
            scoring_function=scoring_function,
            vina_backend=vina_backend,
            vina_command=vina_command,
            vina_cpu=vina_cpu,
            seed=seed,
            spacing=spacing,
            energy_range=energy_range,
            min_rmsd=min_rmsd,
            run_id=run_id,
            protocol_metadata=protocol_metadata,
            engine_config=engine_config,
            executor_name=executor_name,
            depends_on=depends_on,
            skip_existing=skip_existing,
            payload=payload,
            check_required=check_required,
        )
    if hit_cap and hit_threshold is None:
        raise ValueError(
            "docking.run needs hit_threshold when hit_cap is set: the threshold is the "
            "scientific criterion, the cap is only what makes the run end."
        )
    program_spec = self.get_program_spec(program)
    resolved_engine_config = _validated_engine_config(
        program_spec,
        engine_config,
        exhaustiveness=exhaustiveness,
        num_modes=num_modes,
        scoring_function=scoring_function,
        vina_backend=vina_backend,
        vina_command=vina_command,
        vina_cpu=vina_cpu,
        seed=seed,
        spacing=spacing,
        energy_range=energy_range,
        min_rmsd=min_rmsd,
    )
    resolved_batch_size = max(1, int(batch_size or app_config(self.runtime).docking.batch_size))
    ligand_set_ref = None if ligand_set is None or is_molecule_scope(ligand_set) else ensure_molecule_set_ref(
        self.runtime, ligand_set, name="docking_ligand_input")
    receptor_set_ref = None if receptor_set is None or is_molecule_scope(receptor_set) else ensure_molecule_set_ref(
        self.runtime, receptor_set, name="docking_receptor_input")
    ligand_scope = scope_payload(ligand_set) if is_molecule_scope(ligand_set) else {}
    receptor_scope = scope_payload(receptor_set) if is_molecule_scope(receptor_set) else {}
    ligand_filters = self._program_scope_filters(ligand_scope, role="ligand", program=program_spec)
    receptor_filters = self._program_scope_filters(receptor_scope, role="receptor", program=program_spec)
    if box_center is None or box_size is None:
        resolved_grid = self._grid_from_receptor_source(
            receptor_set if is_molecule_scope(receptor_set) else receptor_set_ref,
            engine=program_spec.preparation_engine,
        )
        if resolved_grid is not None:
            box_center, box_size, spacing = resolved_grid
    if (box_center is None) ^ (box_size is None):
        raise ValueError("docking.run requires both box_center and box_size when either one is provided.")
    # Always resolve counts: they gate readiness (when requested) AND give the true
    # chunk total so progress is computed against the real pair count instead of
    # completed/emitted-so-far (a lazy generator otherwise leaves total_chunks unknown,
    # making progress hover near 50-60% regardless of actual completion).
    requirement_check = self.check_required(
        program=program_spec.key,
        ligand_set=ligand_set if is_molecule_scope(ligand_set) else ligand_set_ref,
        receptor_set=receptor_set if is_molecule_scope(receptor_set) else receptor_set_ref,
    )
    if check_required and not bool(requirement_check.get("ready")):
        missing = dict(requirement_check.get("missing") or {})
        counts = dict(requirement_check.get("counts") or {})
        required_jobs = dict(requirement_check.get("required_jobs") or {})
        # `missing` holds capped samples, so the real numbers come from `counts`.
        raise RuntimeError(
            f"docking.run requirements are not satisfied for docking.{program_spec.key}. "
            f"counts={counts}; missing_sample={missing}; required_jobs={required_jobs}"
        )
    run_counts = dict(requirement_check.get("counts") or {})
    # The docking feed pairs every prepared ligand with every prepared receptor
    # (receptors without a grid still emit a failed-pair chunk), batched by batch_size.
    total_pairs = int(run_counts.get("ligands_ready") or 0) * int(run_counts.get("receptors_ready") or 0)
    if check_required and total_pairs <= 0:
        # Fail early and clearly instead of submitting a job whose feed yields no pairs
        # (which would otherwise complete silently with zero results).
        raise RuntimeError(
            f"docking.run has no valid ligand-receptor pairs to dock for docking.{program_spec.key}: "
            f"counts={run_counts}. Import and prepare at least one ligand and one receptor first."
        )
    # The feed applies skip_existing after resolving the scoped cross-product. Its declared
    # total must use that same filtered count; over-declaring makes Molsuite wait forever for
    # chunks that the feed intentionally never emits.
    pending_pairs = count_pending_docking_pairs(
        project_db=self.runtime.workspace.project_db,
        engine=program_spec.docking_engine,
        preparation_engine=program_spec.preparation_engine,
        ligand_set_id=None if ligand_set_ref is None else int(ligand_set_ref.id),
        receptor_set_id=None if receptor_set_ref is None else int(receptor_set_ref.id),
        ligand_filters=ligand_filters,
        receptor_filters=receptor_filters,
        protocol_metadata=protocol_metadata,
        skip_existing=skip_existing,
        requires_ligand_preparation=program_spec.requires_ligand_preparation,
        requires_receptor_preparation=program_spec.requires_receptor_preparation,
    )
    total_chunks = (
        -(-pending_pairs // resolved_batch_size) if pending_pairs > 0 else 0
    )
    resolved_output = (
        str(Path(output_dir).expanduser().resolve())
        if output_dir is not None
        else str(self.runtime.get_project_resource_path("docking_results"))
    )
    resolved_run_id = str(run_id or uuid4().hex)
    params = DockingJobParams(
        output_dir=resolved_output,
        batch_size=resolved_batch_size,
        row_batch_size=batch_size_for("ligand", self.runtime),
        ligand_set_id=None if ligand_set_ref is None else int(ligand_set_ref.id),
        receptor_set_id=None if receptor_set_ref is None else int(receptor_set_ref.id),
        ligand_filters=ligand_filters,
        receptor_filters=receptor_filters,
        engine=program_spec.docking_engine,
        preparation_engine=program_spec.preparation_engine,
        requires_ligand_preparation=program_spec.requires_ligand_preparation,
        requires_receptor_preparation=program_spec.requires_receptor_preparation,
        requires_binding_site=program_spec.requires_binding_site,
        exhaustiveness=int(resolved_engine_config.get("exhaustiveness", exhaustiveness)),
        num_modes=int(resolved_engine_config.get("num_modes", num_modes)),
        box_center=box_center,
        box_size=box_size,
        scoring_function=str(resolved_engine_config.get("scoring_function", scoring_function)),
        vina_backend=str(resolved_engine_config.get("vina_backend", vina_backend)),
        vina_command=str(resolved_engine_config.get("vina_command", vina_command)),
        vina_cpu=int(resolved_engine_config.get("vina_cpu", vina_cpu)),
        seed=int(resolved_engine_config.get("seed", seed)),
        spacing=float(resolved_engine_config.get("spacing", spacing)),
        energy_range=float(resolved_engine_config.get("energy_range", energy_range)),
        min_rmsd=float(resolved_engine_config.get("min_rmsd", min_rmsd)),
        run_id=resolved_run_id,
        protocol_metadata=dict(protocol_metadata or {}),
        engine_config=resolved_engine_config,
        skip_existing=skip_existing,
        compute_diagram=bool(compute_diagram),
        diagram_format=str(diagram_format or "png"),
        hit_threshold=hit_threshold,
        hit_cap=max(0, int(hit_cap)),
    )
    return self.runtime.submit_job(
        docking_job,
        params=params.model_dump(mode="python"),
        executor_name=executor_name,
        depends_on=depends_on,
        # Each docking chunk runs one engine process that uses `vina_cpu` threads
        # internally, so reserve that many CPU tokens from the pool to avoid
        # oversubscription (pool limits concurrency to floor(cpus / vina_cpu)).
        cpu_required=program_spec.resource_requirements(resolved_engine_config)["cpu_required"],
        # Declare the real chunk total so progress = processed/total (not processed/emitted).
        total_chunks=total_chunks,
    )

ligand_summaries

ligand_summaries(*, limit: int = 200, offset: int = 0, receptor_id: int | None = None, only_completed: bool = False, score_lte: float | None = None, metric_key: str | None = None, metric_lte: float | None = None, metric_gte: float | None = None, metric_filters: list[tuple[str, str, float]] | None = None, protocol_hash: str | None = None, exclude_run_kind: str | None = 'redocking')

(best pose, pose count) per ligand, one page at a time.

Source code in src/amdockvs/docking/api.py
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
def ligand_summaries(
        self,
        *,
        limit: int = 200,
        offset: int = 0,
        receptor_id: int | None = None,
        only_completed: bool = False,
        score_lte: float | None = None,
        metric_key: str | None = None,
        metric_lte: float | None = None,
        metric_gte: float | None = None,
        metric_filters: list[tuple[str, str, float]] | None = None,
        protocol_hash: str | None = None,
        exclude_run_kind: str | None = "redocking",
):
    """(best pose, pose count) per ligand, one page at a time."""
    return results_repo.list_ligand_result_summaries(
        self._project_db(),
        limit=limit,
        offset=offset,
        receptor_id=receptor_id,
        only_completed=only_completed,
        score_lte=score_lte,
        metric_key=metric_key,
        metric_lte=metric_lte,
        metric_gte=metric_gte,
        metric_filters=metric_filters,
        protocol_hash=protocol_hash,
        exclude_run_kind=exclude_run_kind,
    )

compute_diagrams

compute_diagrams(*, result_ids: list[int] | None = None, run_id: str = '', receptor_id: int | None = None, score_lte: float | None = None, pose_rank: int | None = 1, fmt: str = 'png', chunk_size: int = 64, replace_existing: bool = False, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None) -> str

Render 2D interaction diagrams for docking poses as a standalone job.

Files land next to each pose (see diagram.diagram_path_for); nothing is written to the DB, so re-run with replace_existing=True to refresh.

Source code in src/amdockvs/docking/api.py
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
def compute_diagrams(
        self,
        *,
        result_ids: list[int] | None = None,
        run_id: str = "",
        receptor_id: int | None = None,
        score_lte: float | None = None,
        pose_rank: int | None = 1,
        fmt: str = "png",
        chunk_size: int = 64,
        replace_existing: bool = False,
        executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
        depends_on: list[str] | None = None,
) -> str:
    """Render 2D interaction diagrams for docking poses as a standalone job.

    Files land next to each pose (see ``diagram.diagram_path_for``); nothing
    is written to the DB, so re-run with ``replace_existing=True`` to refresh.
    """
    self.runtime._require_active_project()
    params = DiagramJobParams(
        result_ids=[int(value) for value in (result_ids or []) if int(value) > 0],
        run_id=str(run_id or ""),
        receptor_id=receptor_id,
        score_lte=score_lte,
        pose_rank=pose_rank,
        fmt=str(fmt or "png"),
        chunk_size=max(1, int(chunk_size)),
        replace_existing=bool(replace_existing),
    )
    return self.runtime.submit_job(
        diagram_job,
        params=params.model_dump(mode="python"),
        executor_name=executor_name,
        depends_on=depends_on,
    )

diagram_path

diagram_path(*, pose_path: str, pose_rank: int = 1, fmt: str = 'png') -> str

Convention path for a pose's diagram (may not exist yet).

Source code in src/amdockvs/docking/api.py
1428
1429
1430
1431
1432
1433
def diagram_path(self, *, pose_path: str, pose_rank: int = 1, fmt: str = "png") -> str:
    """Convention path for a pose's diagram (may not exist yet)."""
    from amdockvs.docking.results.diagram import diagram_path_for

    suffix = ".svg" if str(fmt).lower() == "svg" else ".png"
    return str(diagram_path_for(pose_path, pose_rank, suffix=suffix))

Diversity

DiversityAPI dataclass

DiversityAPI(runtime: Any)

Methods:

Name Description
cluster_job

Submit the clustering job over the DB; results land in clustering_results. Returns

load_universe

Load a scope's fingerprint matrix + 2-D projection for the interactive view. Samples a

cluster_loaded

Cluster an already-loaded UniverseMatrix — the cheap step that re-runs on every knob

analyze

Convenience: load_universe then cluster_loaded in one call (scripting/preview).

scope_count

How many molecules a scope holds — the cheap count used to route serial vs. parallel

run_selection

Cluster the ENTIRE scope (no sampling) and pick representatives — the real 'run the

register_run_from_sidecar

Register a finished parallel run as a saved result WITHOUT recomputing anything: the job's

list_runs

Persisted clustering runs, newest first: run id, method, cluster/centroid counts.

pick_representatives

Choose again what represents each cluster of a finished run. Nothing is clustered

members_clause

Opaque SmartTable adapter: the molecules of one cluster, or the run's representatives

exclude_non_representatives

Mark every non-representative of a run as excluded (the flag Filter uses; reversible).

save_selection_as_set

Persist the chosen representatives as a molecule set (an enrichment set) so the rest of

molblock_for

A 2-D-drawable molblock for one molecule (its stored structure file), or None if there's

save_clustering_result

Persist an applied clustering as a durable run: the graph points (x, y, molecule_id,

list_clustering_results

Saved runs, newest first — for the results table.

load_clustering_result

Everything needed to redraw a saved run (graph points + cluster stats + evr) — read the

cluster_job

cluster_job(*, method: str = 'bitbirch', threshold: float = 0.65, per_cluster: int = 1, molecule_set: MoleculeSetRef | int | None = None, molecule_filters: dict[str, Any] | None = None, fp_radius: int = 2, fp_nbits: int = 2048, with_activity: str = '', cluster_run_id: str = '', num_cpus: int | None = None, executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR, depends_on: list[str] | None = None) -> str

Submit the clustering job over the DB; results land in clustering_results. Returns the job id. The run is tagged with cluster_run_id (auto if blank) — read it back with list_runs / get_run or persist its centroids with save_centroids_as_set. with_activity (an activity endpoint) narrows the scope to the ligands measured for it.

Each cluster starts with its per_cluster centroid-nearest members as representatives; pick_representatives chooses again, by any mode, without clustering again.

num_cpus sets the parallelism: >1 runs bblean multiround with that many processes and the job requests exactly that many CPUs (cpu_required) so mf schedules it correctly. When None it is derived from the scope size via plan_cpus.

Source code in src/amdockvs/diversity/api.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def cluster_job(
    self,
    *,
    method: str = "bitbirch",
    threshold: float = 0.65,
    per_cluster: int = 1,
    molecule_set: MoleculeSetRef | int | None = None,
    molecule_filters: dict[str, Any] | None = None,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    with_activity: str = "",
    cluster_run_id: str = "",
    num_cpus: int | None = None,
    executor_name: str = DEFAULT_LOCAL_CPU_EXECUTOR,
    depends_on: list[str] | None = None,
) -> str:
    """Submit the clustering job over the DB; results land in ``clustering_results``. Returns
    the job id. The run is tagged with ``cluster_run_id`` (auto if blank) — read it back with
    ``list_runs`` / ``get_run`` or persist its centroids with ``save_centroids_as_set``.
    ``with_activity`` (an activity endpoint) narrows the scope to the ligands measured for it.

    Each cluster starts with its ``per_cluster`` centroid-nearest members as representatives;
    ``pick_representatives`` chooses again, by any mode, without clustering again.

    ``num_cpus`` sets the parallelism: >1 runs bblean multiround with that many processes and the
    job **requests exactly that many CPUs** (``cpu_required``) so mf schedules it correctly. When
    None it is derived from the scope size via ``plan_cpus``."""
    self.runtime._require_active_project()
    if num_cpus is None:
        num_cpus = plan_cpus(
            self.scope_count(
                molecule_set=molecule_set, molecule_filters=molecule_filters,
                fp_radius=fp_radius, fp_nbits=fp_nbits, with_activity=with_activity,
            )
        )
    num_cpus = max(1, int(num_cpus))
    params = SelectionClusterJobParams(
        batch_size=app_config(self.runtime).diversity.rows_per_task,
        method=method,
        threshold=threshold,
        per_cluster=per_cluster,
        molecule_set_id=None if molecule_set is None else int(getattr(molecule_set, "id", molecule_set)),
        molecule_filters=dict(molecule_filters or {}),
        fp_radius=fp_radius,
        fp_nbits=fp_nbits,
        with_activity=with_activity,
        cluster_run_id=cluster_run_id or uuid.uuid4().hex,
        num_processes=num_cpus,
    )
    return self.runtime.submit_job(
        cluster_molecules_job,
        params=params.model_dump(mode="python"),
        executor_name=executor_name,
        cpu_required=num_cpus,
        depends_on=depends_on,
    )

load_universe

load_universe(*, molecule_set: MoleculeSetRef | int | None = None, molecule_filters: dict[str, Any] | None = None, fp_radius: int = 2, fp_nbits: int = 2048, sample_limit: int = DEFAULT_SAMPLE_LIMIT, seed: int = 0, basis: Any = None, exclude_ids: Any = None) -> UniverseMatrix

Load a scope's fingerprint matrix + 2-D projection for the interactive view. Samples a random sample_limit subset (seeded by seed so each preview draws a fresh subset), skipping exclude_ids (molecules already previewed) so repeated previews cover more of the space. Projects onto basis when given (a prior preview's fitted PCA axes) so runs overlay in one comparable space; otherwise fits a new basis and returns it. Heavy (DB read + PCA); call off the GUI thread. Cheap re-clustering then uses cluster_loaded.

Source code in src/amdockvs/diversity/api.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
def load_universe(
    self,
    *,
    molecule_set: MoleculeSetRef | int | None = None,
    molecule_filters: dict[str, Any] | None = None,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    sample_limit: int = DEFAULT_SAMPLE_LIMIT,
    seed: int = 0,
    basis: Any = None,
    exclude_ids: Any = None,
) -> UniverseMatrix:
    """Load a scope's fingerprint matrix + 2-D projection for the interactive view. Samples a
    random ``sample_limit`` subset (seeded by ``seed`` so each preview draws a *fresh* subset),
    skipping ``exclude_ids`` (molecules already previewed) so repeated previews cover more of
    the space. Projects onto ``basis`` when given (a prior preview's fitted PCA axes) so runs
    overlay in one comparable space; otherwise fits a new basis and returns it. Heavy (DB read +
    PCA); call off the GUI thread. Cheap re-clustering then uses ``cluster_loaded``."""
    import random

    from amdockvs.diversity.clustering import fit_project_2d, fp_matrix_from_bitstrings, project_2d_onto

    self.runtime._require_active_project()
    params = SelectionClusterJobParams(
        batch_size=app_config(self.runtime).diversity.rows_per_task,
        molecule_set_id=None if molecule_set is None else int(getattr(molecule_set, "id", molecule_set)),
        molecule_filters=dict(molecule_filters or {}),
        fp_radius=fp_radius,
        fp_nbits=fp_nbits,
    )
    project_db = self.runtime.workspace.project_db
    skip = {int(i) for i in (exclude_ids or ())}
    rows, total, eligible = _reservoir_sample(
        scope_molecule_rows(project_db, params),
        limit=int(sample_limit),
        seed=int(seed),
        skip=skip,
    )
    sampled = eligible > int(sample_limit)
    stored = stored_fingerprints_for_ids(
        project_db, [int(r["id"]) for r in rows], radius=fp_radius, nbits=fp_nbits
    )
    ordered_ids: list[int] = []
    bitstrings: list[bytes] = []
    for row in rows:
        mol_id = int(row["id"])
        fp = stored.get(mol_id) or _fingerprint_from_file(row, radius=fp_radius, nbits=fp_nbits)
        if fp is None:
            continue
        ordered_ids.append(mol_id)
        bitstrings.append(fp)
    x_matrix = fp_matrix_from_bitstrings(bitstrings)
    if basis is None:
        projection, evr, basis = fit_project_2d(x_matrix)
    else:
        projection, evr = project_2d_onto(x_matrix, basis), list(basis.get("evr") or [0.0, 0.0])
    return UniverseMatrix(
        molecule_ids=ordered_ids,
        x_matrix=x_matrix,
        projection=[[float(x), float(y)] for x, y in projection],
        projection_variance=list(evr),
        total_in_scope=total,
        sampled=sampled,
        basis=basis,
    )

cluster_loaded staticmethod

cluster_loaded(universe: UniverseMatrix, *, method: str = 'bitbirch', per_cluster: int = 1, **method_params: Any) -> AnalysisResult

Cluster an already-loaded UniverseMatrix — the cheap step that re-runs on every knob change without touching the DB. method_params (threshold, batch_size, …) go straight to the registered method, so each method's own knobs pass through untouched.

Source code in src/amdockvs/diversity/api.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
@staticmethod
def cluster_loaded(
    universe: UniverseMatrix,
    *,
    method: str = "bitbirch",
    per_cluster: int = 1,
    **method_params: Any,
) -> AnalysisResult:
    """Cluster an already-loaded ``UniverseMatrix`` — the cheap step that re-runs on every knob
    change without touching the DB. ``method_params`` (threshold, batch_size, …) go straight to
    the registered method, so each method's own knobs pass through untouched."""
    threshold = float(method_params.get("threshold", 0.65))
    if not universe.molecule_ids:
        return AnalysisResult(
            [], [], [], {"n_clusters": 0, "n_molecules": 0, "clusters": []}, [], [0.0, 0.0],
            method, threshold, universe.total_in_scope, universe.sampled,
        )
    result = cluster_and_select(
        universe.x_matrix, method=method, per_cluster=per_cluster, with_projection=False, **method_params
    )
    rep_ids = [universe.molecule_ids[i] for i in result.representative_indices]
    return AnalysisResult(
        molecule_ids=universe.molecule_ids,
        labels=[int(v) for v in result.labels],
        representative_ids=rep_ids,
        stats=result.stats,
        projection=universe.projection,
        projection_variance=universe.projection_variance,
        method=method,
        threshold=threshold,
        total_in_scope=universe.total_in_scope,
        sampled=universe.sampled,
    )

analyze

analyze(*, method: str = 'bitbirch', threshold: float = 0.65, per_cluster: int = 1, molecule_set: MoleculeSetRef | int | None = None, molecule_filters: dict[str, Any] | None = None, fp_radius: int = 2, fp_nbits: int = 2048, sample_limit: int = DEFAULT_SAMPLE_LIMIT) -> AnalysisResult

Convenience: load_universe then cluster_loaded in one call (scripting/preview).

Source code in src/amdockvs/diversity/api.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def analyze(
    self,
    *,
    method: str = "bitbirch",
    threshold: float = 0.65,
    per_cluster: int = 1,
    molecule_set: MoleculeSetRef | int | None = None,
    molecule_filters: dict[str, Any] | None = None,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    sample_limit: int = DEFAULT_SAMPLE_LIMIT,
) -> AnalysisResult:
    """Convenience: ``load_universe`` then ``cluster_loaded`` in one call (scripting/preview)."""
    universe = self.load_universe(
        molecule_set=molecule_set,
        molecule_filters=molecule_filters,
        fp_radius=fp_radius,
        fp_nbits=fp_nbits,
        sample_limit=sample_limit,
    )
    return self.cluster_loaded(universe, method=method, threshold=threshold, per_cluster=per_cluster)

scope_count

scope_count(*, molecule_set: MoleculeSetRef | int | None = None, molecule_filters: dict[str, Any] | None = None, fp_radius: int = 2, fp_nbits: int = 2048, with_activity: str = '') -> int

How many molecules a scope holds — the cheap count used to route serial vs. parallel (no fingerprint load, no clustering). Reads ids only via the MolSuite query API.

Source code in src/amdockvs/diversity/api.py
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
def scope_count(
    self,
    *,
    molecule_set: MoleculeSetRef | int | None = None,
    molecule_filters: dict[str, Any] | None = None,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    with_activity: str = "",
) -> int:
    """How many molecules a scope holds — the cheap count used to route serial vs. parallel
    (no fingerprint load, no clustering). Reads ids only via the MolSuite query API."""
    self.runtime._require_active_project()
    params = SelectionClusterJobParams(
        batch_size=app_config(self.runtime).diversity.rows_per_task,
        molecule_set_id=None if molecule_set is None else int(getattr(molecule_set, "id", molecule_set)),
        molecule_filters=dict(molecule_filters or {}),
        fp_radius=fp_radius,
        fp_nbits=fp_nbits,
        with_activity=with_activity,
    )
    return scope_molecule_count(self.runtime.workspace.project_db, params)

run_selection

run_selection(*, method: str = 'bitbirch', per_cluster: int = 1, molecule_set: MoleculeSetRef | int | None = None, molecule_filters: dict[str, Any] | None = None, fp_radius: int = 2, fp_nbits: int = 2048, **method_params: Any) -> dict[str, Any]

Cluster the ENTIRE scope (no sampling) and pick representatives — the real 'run the selection' behind the interactive Preview. Every molecule's fingerprint is loaded and clustered (bblean is fast); only the 2-D graph basis is fitted on a random sub-sample and every point projected onto it (cheap), so the plot scales without the full-N SVD. Returns the same mapping as cluster_loaded, over the whole scope. Heavy — call off the GUI thread. Raises if the scope exceeds INLINE_RUN_LIMIT (that needs the parallel job).

Source code in src/amdockvs/diversity/api.py
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def run_selection(
    self,
    *,
    method: str = "bitbirch",
    per_cluster: int = 1,
    molecule_set: MoleculeSetRef | int | None = None,
    molecule_filters: dict[str, Any] | None = None,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    **method_params: Any,
) -> dict[str, Any]:
    """Cluster the ENTIRE scope (no sampling) and pick representatives — the real 'run the
    selection' behind the interactive Preview. Every molecule's fingerprint is loaded and
    clustered (bblean is fast); only the 2-D graph basis is fitted on a random sub-sample and
    every point projected onto it (cheap), so the plot scales without the full-N SVD. Returns the
    same mapping as ``cluster_loaded``, over the whole scope. Heavy — call off the GUI thread.
    Raises if the scope exceeds ``INLINE_RUN_LIMIT`` (that needs the parallel job)."""
    import random

    from amdockvs.diversity.clustering import (
        fit_project_2d,
        fp_matrix_from_bitstrings,
        project_2d_onto,
    )

    self.runtime._require_active_project()
    params = SelectionClusterJobParams(
        batch_size=app_config(self.runtime).diversity.rows_per_task,
        molecule_set_id=None if molecule_set is None else int(getattr(molecule_set, "id", molecule_set)),
        molecule_filters=dict(molecule_filters or {}),
        fp_radius=fp_radius,
        fp_nbits=fp_nbits,
    )
    project_db = self.runtime.workspace.project_db
    # Stop one row past the limit instead of loading the scope first: a scope too big for the
    # inline path must not be materialized just to be rejected.
    rows = list(islice(scope_molecule_rows(project_db, params), INLINE_RUN_LIMIT + 1))
    if len(rows) > INLINE_RUN_LIMIT:
        raise ValueError(
            f"More than {INLINE_RUN_LIMIT} molecules exceed the serial limit — this scope belongs "
            f"on the parallel clustering job (cluster_job), not the inline path."
        )
    total = len(rows)
    stored = stored_fingerprints_for_ids(
        project_db, [int(r["id"]) for r in rows], radius=fp_radius, nbits=fp_nbits
    )
    ordered_ids: list[int] = []
    bitstrings: list[bytes] = []
    for row in rows:
        mol_id = int(row["id"])
        fp = stored.get(mol_id) or _fingerprint_from_file(row, radius=fp_radius, nbits=fp_nbits)
        if fp is None:
            continue
        ordered_ids.append(mol_id)
        bitstrings.append(fp)
    x_matrix = fp_matrix_from_bitstrings(bitstrings)
    universe = UniverseMatrix(
        molecule_ids=ordered_ids, x_matrix=x_matrix, projection=[],
        projection_variance=[0.0, 0.0], total_in_scope=total, sampled=False,
    )
    n = len(ordered_ids)
    if n:
        fit_idx = list(range(n))
        if n > PROJECT_FIT_SAMPLE:
            fit_idx = random.Random(0).sample(fit_idx, PROJECT_FIT_SAMPLE)
        _coords, evr, basis = fit_project_2d(x_matrix[fit_idx])
        proj = project_2d_onto(x_matrix, basis)
        universe.projection = [[float(x), float(y)] for x, y in proj]
        universe.projection_variance = list(evr)
    return self.cluster_loaded(
        universe, method=method, per_cluster=per_cluster, **method_params
    ).to_mapping()

register_run_from_sidecar

register_run_from_sidecar(cluster_run_id: str, *, method: str, threshold: float, scope_label: str, fp_radius: int = 2, fp_nbits: int = 2048) -> str

Register a finished parallel run as a saved result WITHOUT recomputing anything: the job's task already wrote the graph sidecar <root>/clustering_runs/<run_id>.parquet (PCA points + cluster/centroid flags). Read it for the counts + aggregated cluster sizes and insert the compact ClusteringRun summary pointing at it. No fingerprints, no PCA here.

Source code in src/amdockvs/diversity/api.py
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
def register_run_from_sidecar(
    self,
    cluster_run_id: str,
    *,
    method: str,
    threshold: float,
    scope_label: str,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
) -> str:
    """Register a finished parallel run as a saved result WITHOUT recomputing anything: the job's
    task already wrote the graph sidecar ``<root>/clustering_runs/<run_id>.parquet`` (PCA points +
    cluster/centroid flags). Read it for the counts + aggregated cluster sizes and insert the
    compact ``ClusteringRun`` summary pointing at it. No fingerprints, no PCA here."""
    import pandas as pd

    self.runtime._require_active_project()
    snapshot = self._project_root() / "clustering_runs" / f"{cluster_run_id}.parquet"
    if snapshot.exists():
        frame = pd.read_parquet(snapshot)
        n_molecules = int(len(frame))
        n_clusters = int(frame["cluster_id"].nunique()) if n_molecules else 0
        n_reps = int(frame["is_centroid"].sum()) if n_molecules else 0
        cluster_stats = [
            {"cluster_id": int(cid), "size": int(size), "tightness": 0.0}
            for cid, size in frame.groupby("cluster_id").size().items()
        ]
        snapshot_path = str(snapshot)
    else:  # sidecar missing (e.g. write failed) → counts-only summary, no graph
        n_molecules = n_clusters = n_reps = 0
        cluster_stats = []
        snapshot_path = ""
    row = ClusteringRun(
        run_id=cluster_run_id, method=str(method), threshold=float(threshold),
        scope_label=str(scope_label), fp_radius=int(fp_radius), fp_nbits=int(fp_nbits),
        n_molecules=n_molecules, n_clusters=n_clusters, n_reps=n_reps,
        snapshot_path=snapshot_path, evr=[0.0, 0.0], cluster_stats=cluster_stats,
    )
    with self.runtime.workspace.project_db.get_session() as session:
        session.add(row)
        session.commit()
    return cluster_run_id

list_runs

list_runs() -> list[dict[str, Any]]

Persisted clustering runs, newest first: run id, method, cluster/centroid counts.

Source code in src/amdockvs/diversity/api.py
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
def list_runs(self) -> list[dict[str, Any]]:
    """Persisted clustering runs, newest first: run id, method, cluster/centroid counts."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        rows = session.exec(select(ClusteringResult)).all()
    runs: dict[str, dict[str, Any]] = {}
    for row in rows:
        entry = runs.setdefault(
            row.cluster_run_id,
            {"cluster_run_id": row.cluster_run_id, "method": row.method, "created_at": row.created_at,
             "n_molecules": 0, "n_clusters": set(), "n_centroids": 0},
        )
        entry["n_molecules"] += 1
        entry["n_clusters"].add(int(row.cluster_id))
        if row.is_centroid:
            entry["n_centroids"] += 1
    out = []
    for entry in runs.values():
        entry["n_clusters"] = len(entry["n_clusters"])
        out.append(entry)
    return sorted(out, key=lambda e: str(e.get("created_at") or ""), reverse=True)

pick_representatives

pick_representatives(cluster_run_id: str, *, mode: str = 'centroid', per_cluster: int = 1, fraction: float = 0.1, rank_by: str = '') -> dict[str, int]

Choose again what represents each cluster of a finished run. Nothing is clustered again: the run already ranks every member, and this only reads that ranking (modes: see clustering.pick_representatives). rank_by (an activity endpoint) replaces similarity as what 'extremes' / 'quartiles' / 'proportional' spread over. Rewrites the run's representative flags; returns {"n_molecules", "n_clusters", "n_reps"}.

ponytail: in-process over the run's columns (~20 B/molecule) and one UPDATE per 900 picks. Make it a job when a run outgrows the tens of millions.

Source code in src/amdockvs/diversity/api.py
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def pick_representatives(
    self,
    cluster_run_id: str,
    *,
    mode: str = "centroid",
    per_cluster: int = 1,
    fraction: float = 0.1,
    rank_by: str = "",
) -> dict[str, int]:
    """Choose again what represents each cluster of a finished run. Nothing is clustered
    again: the run already ranks every member, and this only reads that ranking (modes:
    see ``clustering.pick_representatives``). ``rank_by`` (an activity endpoint) replaces
    similarity as what 'extremes' / 'quartiles' / 'proportional' spread over. Rewrites the
    run's representative flags; returns {"n_molecules", "n_clusters", "n_reps"}.

    ponytail: in-process over the run's columns (~20 B/molecule) and one UPDATE per 900
    picks. Make it a job when a run outgrows the tens of millions."""
    import pandas as pd

    self.runtime._require_active_project()
    run_id = str(cluster_run_id)
    sidecar = self._project_root() / "clustering_runs" / f"{run_id}.parquet"
    frame = pd.read_parquet(sidecar) if sidecar.exists() else None
    if frame is None or "similarity" not in frame:
        raise ValueError("This run keeps no member rankings (it predates them): cluster again once.")
    values = None
    if rank_by:
        activity = activity_values(self.runtime.workspace.project_db, rank_by)
        values = frame["molecule_id"].map(activity).to_numpy(dtype=float)
    labels = frame["cluster_id"].to_numpy()
    picks = pick_representatives(
        labels, frame["similarity"].to_numpy(), mode=mode, per_cluster=per_cluster, fraction=fraction,
        centrality=frame["centrality"].to_numpy(), values=values,
    )
    flags = np.zeros(len(frame), dtype=bool)
    flags[picks] = True
    picked_ids = [int(i) for i in frame["molecule_id"].to_numpy()[picks]]
    of_run = ClusteringResult.cluster_run_id == run_id
    with self.runtime.workspace.project_db.get_session() as session:
        session.exec(sql_update(ClusteringResult).where(of_run).values(is_centroid=False))
        for start in range(0, len(picked_ids), 900):
            session.exec(
                sql_update(ClusteringResult)
                .where(of_run, ClusteringResult.molecule_id.in_(picked_ids[start:start + 900]))
                .values(is_centroid=True)
            )
        session.exec(sql_update(ClusteringRun).where(ClusteringRun.run_id == run_id).values(n_reps=len(picks)))
        session.commit()
    frame["is_centroid"] = flags
    frame.to_parquet(sidecar, index=False)
    return {"n_molecules": len(frame), "n_clusters": int(len(np.unique(labels))), "n_reps": len(picks)}

members_clause

members_clause(cluster_run_id: str, cluster_id: int | None = None)

Opaque SmartTable adapter: the molecules of one cluster, or the run's representatives when no cluster is given. A subquery, so it costs the same for 10 members or 10^6.

Source code in src/amdockvs/diversity/api.py
580
581
582
583
584
585
586
587
588
589
590
def members_clause(self, cluster_run_id: str, cluster_id: int | None = None):
    """Opaque SmartTable adapter: the molecules of one cluster, or the run's representatives
    when no cluster is given. A subquery, so it costs the same for 10 members or 10^6."""
    picked = select(ClusteringResult.molecule_id).where(
        ClusteringResult.cluster_run_id == str(cluster_run_id)
    )
    if cluster_id is None:
        picked = picked.where(ClusteringResult.is_centroid == True)  # noqa: E712 - SQL, not Python
    else:
        picked = picked.where(ClusteringResult.cluster_id == int(cluster_id))
    return MoleculeRecord.id.in_(picked)

exclude_non_representatives

exclude_non_representatives(cluster_run_id: str) -> int

Mark every non-representative of a run as excluded (the flag Filter uses; reversible). Returns how many were marked.

Source code in src/amdockvs/diversity/api.py
592
593
594
595
596
597
598
599
600
601
602
603
604
def exclude_non_representatives(self, cluster_run_id: str) -> int:
    """Mark every non-representative of a run as excluded (the flag Filter uses; reversible).
    Returns how many were marked."""
    self.runtime._require_active_project()
    non_reps = [r["molecule_id"] for r in self.get_run(cluster_run_id) if not r["is_centroid"]]
    if not non_reps:
        return 0
    with self.runtime.workspace.project_db.get_session() as session:
        run = session.exec(select(ClusteringRun).where(ClusteringRun.run_id == str(cluster_run_id))).first()
    detail = f"{run.method}, t={run.threshold}" if run is not None else str(cluster_run_id)[:8]
    return int(self.runtime.molecules.set_excluded_state(
        non_reps, excluded=True, reason=f"diversity: non-representative ({detail})"
    ))

save_selection_as_set

save_selection_as_set(molecule_ids: Iterable[int], *, name: str) -> MoleculeSetRef

Persist the chosen representatives as a molecule set (an enrichment set) so the rest of the app (docking, filter, export) can target the redundancy-reduced library.

Source code in src/amdockvs/diversity/api.py
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def save_selection_as_set(self, molecule_ids: Iterable[int], *, name: str) -> MoleculeSetRef:
    """Persist the chosen representatives as a molecule set (an enrichment set) so the rest of
    the app (docking, filter, export) can target the redundancy-reduced library."""
    self.runtime._require_active_project()
    ids = [int(i) for i in molecule_ids]
    if not ids:
        raise ValueError("No molecules to save as a set.")
    ref = create_snapshot_set(
        self.runtime.workspace.project_db,
        entity_kind="molecule",
        name=str(name).strip() or "diverse_selection",
        entity_ids=ids,
        kind=SetPurpose.ENRICHMENT,
    )
    return MoleculeSetRef(id=int(ref.id), kind="snapshot")

molblock_for

molblock_for(molecule_id: int) -> str | None

A 2-D-drawable molblock for one molecule (its stored structure file), or None if there's no usable structure. Best-effort — used by the universe scatter's hover preview.

Source code in src/amdockvs/diversity/api.py
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
def molblock_for(self, molecule_id: int) -> str | None:
    """A 2-D-drawable molblock for one molecule (its stored structure file), or None if there's
    no usable structure. Best-effort — used by the universe scatter's hover preview."""
    from rdkit import Chem

    from amdockvs.core.paths import preferred_molecule_path

    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        rec = session.get(MoleculeRecord, int(molecule_id))
        path = preferred_molecule_path(rec) if rec is not None else None
    if path is None or not path.exists():
        return None
    from amdockvs.io.formats import read_mol

    mol = read_mol(path, remove_hs=True)
    return Chem.MolToMolBlock(mol) if mol is not None else None

save_clustering_result

save_clustering_result(*, points: Iterable[tuple], cluster_stats: list[dict[str, Any]], evr: Iterable[float], method: str, threshold: float, scope_label: str, fp_radius: int = 2, fp_nbits: int = 2048, n_molecules: int | None = None, n_clusters: int | None = None, n_reps: int | None = None) -> str

Persist an applied clustering as a durable run: the graph points (x, y, molecule_id, cluster_id, is_centroid) to a parquet sidecar, and a compact ClusteringRun row (metadata + counts + the sidecar path). Returns the run id. Only the PCA + cluster flags are stored — fingerprints stay in their table, loaded by id if ever needed. Counts default to what the points imply; the parallel path passes them explicitly (it saves no graph sidecar yet).

Source code in src/amdockvs/diversity/api.py
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def save_clustering_result(
    self,
    *,
    points: Iterable[tuple],
    cluster_stats: list[dict[str, Any]],
    evr: Iterable[float],
    method: str,
    threshold: float,
    scope_label: str,
    fp_radius: int = 2,
    fp_nbits: int = 2048,
    n_molecules: int | None = None,
    n_clusters: int | None = None,
    n_reps: int | None = None,
) -> str:
    """Persist an applied clustering as a durable run: the graph points (x, y, molecule_id,
    cluster_id, is_centroid) to a parquet sidecar, and a compact ``ClusteringRun`` row (metadata
    + counts + the sidecar path). Returns the run id. Only the PCA + cluster flags are stored —
    fingerprints stay in their table, loaded by id if ever needed. Counts default to what the
    points imply; the parallel path passes them explicitly (it saves no graph sidecar yet)."""
    import pandas as pd

    self.runtime._require_active_project()
    run_id = uuid.uuid4().hex
    snap_dir = self._project_root() / "clustering_runs"
    snap_dir.mkdir(parents=True, exist_ok=True)
    snapshot = snap_dir / f"{run_id}.parquet"
    frame = pd.DataFrame(
        list(points), columns=["x", "y", "molecule_id", "cluster_id", "is_centroid"]
    )
    frame.to_parquet(snapshot, index=False)
    row = ClusteringRun(
        run_id=run_id, method=str(method), threshold=float(threshold), scope_label=str(scope_label),
        fp_radius=int(fp_radius), fp_nbits=int(fp_nbits),
        n_molecules=int(n_molecules if n_molecules is not None else len(frame)),
        n_clusters=int(n_clusters if n_clusters is not None else len(cluster_stats or [])),
        n_reps=int(n_reps if n_reps is not None else (frame["is_centroid"].sum() if len(frame) else 0)),
        snapshot_path=str(snapshot), evr=list(evr or [0.0, 0.0]), cluster_stats=list(cluster_stats or []),
    )
    with self.runtime.workspace.project_db.get_session() as session:
        session.add(row)
        session.commit()
    return run_id

list_clustering_results

list_clustering_results() -> list[dict[str, Any]]

Saved runs, newest first — for the results table.

Source code in src/amdockvs/diversity/api.py
692
693
694
695
696
697
698
699
700
701
702
def list_clustering_results(self) -> list[dict[str, Any]]:
    """Saved runs, newest first — for the results table."""
    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        rows = session.exec(select(ClusteringRun).order_by(ClusteringRun.id.desc())).all()
    return [
        {"run_id": r.run_id, "created_at": r.created_at, "method": r.method, "threshold": r.threshold,
         "scope_label": r.scope_label, "n_molecules": r.n_molecules, "n_clusters": r.n_clusters,
         "n_reps": r.n_reps}
        for r in rows
    ]

load_clustering_result

load_clustering_result(run_id: str) -> dict[str, Any]

Everything needed to redraw a saved run (graph points + cluster stats + evr) — read the sidecar directly, no re-clustering or PCA recompute.

Source code in src/amdockvs/diversity/api.py
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
def load_clustering_result(self, run_id: str) -> dict[str, Any]:
    """Everything needed to redraw a saved run (graph points + cluster stats + evr) — read the
    sidecar directly, no re-clustering or PCA recompute."""
    import pandas as pd

    self.runtime._require_active_project()
    with self.runtime.workspace.project_db.get_session() as session:
        row = session.exec(select(ClusteringRun).where(ClusteringRun.run_id == str(run_id))).first()
    if row is None:
        raise ValueError(f"Unknown clustering run: {run_id}")
    if row.snapshot_path and Path(row.snapshot_path).exists():
        frame = pd.read_parquet(row.snapshot_path)
    else:
        frame = pd.DataFrame(columns=["x", "y", "molecule_id", "cluster_id", "is_centroid"])
    return {
        "run_id": row.run_id, "method": row.method, "threshold": row.threshold,
        "scope_label": row.scope_label, "n_molecules": row.n_molecules, "n_clusters": row.n_clusters,
        "n_reps": row.n_reps, "evr": list(row.evr or [0.0, 0.0]),
        "cluster_stats": list(row.cluster_stats or []), "points": frame.to_dict("records"),
    }

External tools

ToolAPI dataclass

ToolAPI(runtime: Any)

Methods:

Name Description
status

Return the current readiness of one managed tool, or every tool.

install

Install a tool if needed and return its verified status.

uninstall

Remove a managed tool from disk.

repair

Remove and reinstall a tool whose on-disk installation is suspect.

status

status(tool_id: str | None = None) -> ToolStatus | tuple[ToolStatus, ...]

Return the current readiness of one managed tool, or every tool.

Source code in src/amdockvs/integrations/api.py
22
23
24
25
26
def status(self, tool_id: str | None = None) -> ToolStatus | tuple[ToolStatus, ...]:
    """Return the current readiness of one managed tool, or every tool."""
    if tool_id is None:
        return tuple(tool_statuses(self.runtime))
    return get_tool(tool_id).status(self.runtime)

install

install(tool_id: str) -> ToolStatus

Install a tool if needed and return its verified status.

Source code in src/amdockvs/integrations/api.py
28
29
30
31
32
33
34
35
36
def install(self, tool_id: str) -> ToolStatus:
    """Install a tool if needed and return its verified status."""
    current = self.status(tool_id)
    assert isinstance(current, ToolStatus)
    if not current.installed:
        run_steps(install_steps(self.runtime, tool_id))
    result = self.status(tool_id)
    assert isinstance(result, ToolStatus)
    return result

uninstall

uninstall(tool_id: str) -> str

Remove a managed tool from disk.

Source code in src/amdockvs/integrations/api.py
38
39
40
def uninstall(self, tool_id: str) -> str:
    """Remove a managed tool from disk."""
    return uninstall_tool(self.runtime, tool_id)

repair

repair(tool_id: str) -> ToolStatus

Remove and reinstall a tool whose on-disk installation is suspect.

Source code in src/amdockvs/integrations/api.py
42
43
44
45
def repair(self, tool_id: str) -> ToolStatus:
    """Remove and reinstall a tool whose on-disk installation is suspect."""
    self.uninstall(tool_id)
    return self.install(tool_id)