"""Shared argument groups and parsing helpers for the CLI.
Factored out so that ``plan``, ``score``, and ``map`` accept the same flags with the same
defaults. A knob that means one thing under ``plan`` and another under ``score`` is worse
than no knob.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any, Sequence
from rbfenetmap.core.cost import COST_UNITS
from rbfenetmap.core.intermediates import IntermediateOptions
from rbfenetmap.core.options import (
COMPAT_LEVELS,
AlignmentOptions,
CorePruningPolicy,
MappingOptions,
NetworkOptions,
SoftcorePolicy,
)
__all__ = (
"COMPAT_CLI_PINS",
"add_compat_argument",
"add_cost_units_argument",
"add_ligand_arguments",
"add_mapping_arguments",
"add_network_arguments",
"add_scorer_arguments",
"add_softcore_arguments",
"build_alignment_options",
"build_mapping_options",
"build_network_options",
"explicit_dests",
"parse_key_values",
"resolve_compat",
)
#: What ``--compat LEVEL`` pins, as CLI destination names, per level.
#:
#: **These are literal transcriptions of what the named release did, not a view onto the
#: current defaults.** Deriving them from the parser would be shorter and would defeat the
#: mechanism entirely: when a later version moves a default, a derived table moves with it
#: and silently stops reproducing the version it names.
#:
#: The pinned surface is the *algorithmic* one -- the knobs whose meaning or default may
#: change between releases. Deliberately absent are the settings that describe **this run**
#: rather than **this behaviour**:
#:
#: - ligand-specific intent (``hub``, ``forced_edge``, ``banned_edge``, ``explicit_edge``):
#: banning an edge is a statement about one ligand set, not about a version;
#: - input preparation (``ligands``, ``align`` and friends): that is which molecules go in,
#: not how they are planned;
#: - operational knobs (``jobs``, ``progress``, ``out``, ``export``): they cannot change
#: which network is produced.
#:
#: All three stay usable alongside a compat level, which is what makes it practical rather
#: than merely principled.
COMPAT_CLI_PINS: dict[str, dict[str, Any]] = {
"v0.4": {
# mapping
"mapper": "mcss-e2",
"match_selection": "fewest_fragments",
"mcs_timeout": 60,
"distance_threshold": 2.0,
"mapper_opt": None,
# scoring
"scorer": "linear",
"weights": None,
"weights_file": None,
# soft-core feasibility
"max_softcore_atoms": 12,
"max_softcore_fraction": 0.6,
"min_core_atoms": 4,
"min_mcs_fraction": 0.35,
"core_rmsd_threshold": 2.0,
"ring_policy": "ring_system",
"charge_change_policy": "penalize",
# selection
"planner": "mst",
"pair_strategy": "all_unordered_pairs",
"n_edges": None,
"edges_per_ligand": 2,
"min_cycle_coverage": 1.0,
"allow_disconnected": False,
"edge_direction": "fewer_softcore_first",
"prefilter": "none",
"prefilter_k": 8,
"prefilter_min_tanimoto": 0.4,
"selection_objective": "uniform_redundancy",
# None of these four existed in v0.4.0, so the value that reproduces it is the one
# that makes each a no-op rather than whatever the current default happens to be.
"cycle_coverage_mode": "node",
"max_cycle_size": None,
"max_diameter": None,
"n_redundancy": 2,
"hub_selection": "most_partners",
"pair_evaluation": "eager",
"adaptive_initial_neighbors": 3,
"adaptive_batch_size": 32,
"cbfe": "off",
"cbfe_base_cost": 8.0,
"cbfe_atom_weight": 0.05,
# Clustered planning did not exist in v0.4, so what reproduces it is the setting
# that makes the whole feature a no-op -- not the current default of any knob
# inside it. `cluster_bridges` is pinned alongside it even though `cluster_by`
# already neutralises it, because a pin that depends on another pin to be
# harmless stops being one the moment the other moves.
"cluster_by": "none",
"cluster_bridges": 2,
# v0.4 could not invent a ligand, so every one of these pins to "do not". They are
# pinned rather than omitted because they are algorithmic: leaving them out would
# let `--compat v0.4 --intermediates bridge` quietly plan a network v0.4 could not
# have produced, which is the exact surprise --compat exists to prevent.
"intermediates": "off",
"intermediate_generator": "pairmap",
"max_intermediates": None,
"max_intermediate_gaps": None,
"intermediates_per_gap": 4,
"intermediate_seed": 0xF00D,
"intermediate_pose_attempts": 10,
"intermediate_pose_rmsd_factor": 0.5,
"intermediate_min_link_score": 0.2,
"intermediate_max_dist": 3,
"intermediate_max_cycle": 4,
"intermediate_max_subgraph_dist": 4,
"intermediate_beta": 0.1,
"consistency": "pairwise",
# statistical design -- v0.4.0 had none, so every one of these pins the no-op
"design": "none",
"design_candidate_factor": 3.0,
"design_refine": False,
"design_total_ns": None,
"design_lambda_min": 12,
"design_lambda_max": 24,
}
}
#: Reverse lookup from destination to the flag that sets it, for error messages. A user
#: who typed ``--edges-per-ligand`` should be told about ``--edges-per-ligand``, not about
#: an internal attribute name they have never seen.
_DEST_TO_FLAG: dict[str, str] = {
"mapper_opt": "--mapper-opt",
"mcs_timeout": "--mcs-timeout",
"cbfe": "--cbfe",
"allow_disconnected": "--allow-disconnected",
"weights": "--weights",
"weights_file": "--weights-file",
"intermediates": "--intermediates",
}
def _flag_for(dest: str) -> str:
"""Return the user-facing flag that sets *dest*."""
return _DEST_TO_FLAG.get(dest, "--" + dest.replace("_", "-"))
[docs]
def add_compat_argument(parser: argparse.ArgumentParser) -> None:
"""Add ``--compat``, which pins every algorithmic knob to a released behaviour."""
parser.add_argument(
"--compat",
choices=COMPAT_LEVELS,
metavar="LEVEL",
help=(
"Reproduce a released version's behaviour exactly, pinning every algorithmic "
f"knob to what that version used. Choices: {', '.join(COMPAT_LEVELS)}. Ligand "
"intent (--hub, --banned-edge), input preparation (--align) and operational "
"flags (--jobs, --progress) stay available; naming an algorithmic knob "
"alongside this is a contradiction and is refused."
),
)
[docs]
def add_cost_units_argument(parser: argparse.ArgumentParser) -> None:
"""Add ``--cost-units``, which chooses how a cost *report* is expressed.
Deliberately absent from :data:`COMPAT_CLI_PINS` and from
:class:`~rbfenetmap.core.options.NetworkOptions`. It is a display unit, on the same
footing as ``--format`` or ``--show-rejected``: no planner reads it, no edge moves
because of it, and pinning it would refuse ``--compat v0.4 --cost-units gpu_hours``, a
combination with no contradiction in it at all. Reproducing a released behaviour is a
statement about which network comes out, not about what units it is printed in.
"""
parser.add_argument(
"--cost-units",
choices=COST_UNITS,
default="score",
help=(
"Units for the reported network cost (default: %(default)s). 'score' is the scorer's own "
"difficulty scale, which orders edges; 'gpu_hours' is estimated machine time from published "
"per-edge measurements, plus a price. Reporting only -- it cannot change which edges are chosen."
),
)
[docs]
def explicit_dests(parser: argparse.ArgumentParser, argv: Sequence[str] | None) -> frozenset[str]:
"""Return the destinations the user actually named on the command line.
Comparing the parsed value against the current default cannot answer this, and the
difference is the whole reason ``--compat`` exists: once a release moves a default, an
unnamed flag and a deliberately-set one become indistinguishable that way, and every
run would be reported as conflicting with the level it asked for.
So this re-parses the same argv against a parser whose defaults are all suppressed.
Only what the user typed survives, which is exactly the question being asked.
Parameters
----------
parser : argparse.ArgumentParser
**A throwaway.** Its defaults are suppressed in place, which leaves it unfit to
parse anything else, so callers pass a freshly built one rather than the parser
whose result they intend to use.
argv : Sequence[str], optional
The same argv the real parse saw. ``None`` means ``sys.argv[1:]``.
"""
_suppress_defaults(parser)
try:
return frozenset(vars(parser.parse_args(list(argv) if argv is not None else None)))
except SystemExit: # pragma: no cover - argparse already reported the usage error
return frozenset()
def _suppress_defaults(parser: argparse.ArgumentParser) -> None:
"""Set every default in *parser*, and in any subparser, to ``SUPPRESS``."""
for action in parser._actions: # noqa: SLF001 - argparse exposes no public equivalent
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
for sub in action.choices.values():
_suppress_defaults(sub)
action.default = argparse.SUPPRESS
[docs]
def resolve_compat(args: argparse.Namespace, explicit: frozenset[str]) -> None:
"""Apply ``--compat`` to *args* in place, refusing any knob it contradicts.
Parameters
----------
args : argparse.Namespace
Parsed arguments. Modified in place.
explicit : frozenset[str]
Destinations the user named, from :func:`explicit_dests`.
Raises
------
SystemExit
Via ``argparse``-style exit, if the user named an algorithmic knob alongside
``--compat``.
Notes
-----
Rejecting rather than silently letting one win follows the rule the package already
applies to ``n_edges`` against ``require_connected``: both resolutions are defensible,
so neither may be chosen on the user's behalf. ``--compat v0.4 --max-diameter 5`` is a
request for v0.4's behaviour and for something v0.4 could not do, and only the user can
say which they meant.
"""
level = getattr(args, "compat", None)
if level is None:
return
pins = COMPAT_CLI_PINS[level]
conflicts = sorted(_flag_for(dest) for dest in pins if dest in explicit)
if conflicts:
raise ValueError(
f"--compat {level} pins every algorithmic knob to what {level} used, so it cannot be "
f"combined with {', '.join(conflicts)}. Drop --compat to set "
f"{'them' if len(conflicts) > 1 else 'it'} explicitly, or drop "
f"{'those flags' if len(conflicts) > 1 else 'that flag'} to reproduce {level}. "
"Ligand intent (--hub, --forced-edge, --banned-edge), input preparation "
"(--align) and operational flags (--jobs, --progress) are not pinned and may be "
"combined with --compat freely."
)
for dest, value in pins.items():
if hasattr(args, dest):
setattr(args, dest, value)
[docs]
def parse_key_values(items: Sequence[str] | None, *, numeric: bool = True) -> dict[str, Any]:
"""Parse repeated ``key=value`` arguments into a dictionary.
Parameters
----------
items : Sequence[str], optional
numeric : bool, optional
Convert values that parse as numbers, and ``true``/``false`` to booleans.
Raises
------
argparse.ArgumentTypeError
If an item has no ``=``. Failing loudly matters here: a mistyped
``--weights softcore_atoms 2`` would otherwise be read as two separate items and
silently ignored, and the run would quietly use the defaults.
"""
parsed: dict[str, Any] = {}
for item in items or ():
key, sep, value = item.partition("=")
if not sep or not key:
raise argparse.ArgumentTypeError(f"Expected key=value, got {item!r}.")
if numeric:
lowered = value.strip().lower()
if lowered in ("true", "false"):
parsed[key] = lowered == "true"
continue
try:
parsed[key] = float(value)
continue
except ValueError:
pass
parsed[key] = value
return parsed
[docs]
def add_ligand_arguments(parser: argparse.ArgumentParser) -> None:
"""Add the ligand-input flags."""
group = parser.add_argument_group("ligand input")
group.add_argument(
"--ligands",
nargs="+",
type=Path,
required=True,
metavar="PATH",
help="SDF, mol2, .smi files, or directories containing them.",
)
group.add_argument(
"--name-property",
default="_Name",
metavar="PROP",
help="Molecule property to read ligand names from (default: %(default)s).",
)
# nargs="?" is unambiguous only because no subcommand takes a positional argument:
# `--align` followed by another flag, or by the end of the line, can only mean the
# const. Adding a positional to plan/score/map would break that quietly.
group.add_argument(
"--align",
nargs="?",
const="mcs",
choices=("mcs", "o3a"),
default=None,
metavar="METHOD",
help=(
"Rigidly align the ligands into a common frame before mapping. Bare --align uses "
"'mcs' (maximum common substructure plus Kabsch, applied outward along a similarity "
"tree); 'o3a' uses Open3DAlign, for sets with no substructure large enough to fit on. "
"Off by default: ligands prepared together are already co-posed, and aligning those "
"would hide a real pose problem rather than solve one."
),
)
group.add_argument(
"--align-reference",
metavar="LIGAND",
help="Ligand whose frame the others are brought into (default: the one with the most heavy atoms).",
)
group.add_argument(
"--align-min-atoms",
type=int,
default=3,
metavar="N",
help="Refuse to align a ligand on fewer than N corresponding heavy atoms (default: %(default)s).",
)
group.add_argument(
"--write-aligned",
type=Path,
metavar="DIR",
help="Write the aligned structures here, one SDF per ligand, for inspection.",
)
[docs]
def add_mapping_arguments(parser: argparse.ArgumentParser) -> None:
"""Add the mapper-selection flags."""
group = parser.add_argument_group("mapping")
group.add_argument("--mapper", default="mcss-e2", help="Mapper plugin (default: %(default)s).")
group.add_argument("--mapper-opt", action="append", metavar="K=V", help="Mapper option, repeatable.")
group.add_argument(
"--match-selection",
choices=("fewest_fragments", "best_rmsd", "first"),
default="fewest_fragments",
help=(
"How to resolve a symmetric common substructure (default: %(default)s). "
"'first' reproduces the historical arbitrary choice."
),
)
group.add_argument(
"--mcs-timeout", type=int, default=60, metavar="SEC", help="MCS search timeout (default: %(default)s)."
)
group.add_argument(
"--distance-threshold",
type=float,
default=2.0,
metavar="A",
help="Geometric cutoff for geometry-based mappers, in angstroms (default: %(default)s).",
)
[docs]
def add_scorer_arguments(parser: argparse.ArgumentParser) -> None:
"""Add the scorer-selection flags.
Lives here rather than in :mod:`rbfenetmap.cli.main` beside its four siblings, so that
the whole ``plan`` surface can be assembled from public functions. Anything that wants
to reason about the flags -- a form generator, a documentation build -- needs the same
five groups, and one of them being private made that impossible without reaching into
another module's underscore.
"""
group = parser.add_argument_group("scoring")
group.add_argument("--scorer", default="linear", help="Scorer plugin (default: %(default)s).")
group.add_argument("--weights", action="append", metavar="K=V", help="Override a scoring weight, repeatable.")
group.add_argument("--weights-file", type=Path, metavar="PATH", help="JSON file of scoring weights.")
[docs]
def add_softcore_arguments(parser: argparse.ArgumentParser) -> None:
"""Add the soft-core feasibility flags."""
group = parser.add_argument_group("soft-core policy")
group.add_argument(
"--max-softcore-atoms",
type=int,
default=12,
metavar="N",
help="Reject an edge whose repaired soft-core exceeds N heavy atoms (default: %(default)s).",
)
group.add_argument(
"--max-softcore-fraction",
type=float,
default=0.6,
metavar="F",
help="Reject when the soft-core exceeds this fraction of a molecule (default: %(default)s).",
)
group.add_argument(
"--min-core-atoms",
type=int,
default=4,
metavar="N",
help="Reject when fewer than N heavy atoms remain in the common core (default: %(default)s).",
)
group.add_argument(
"--min-mcs-fraction",
type=float,
default=0.35,
metavar="F",
help="Reject before repair when the core covers less than F of the smaller ligand (default: %(default)s).",
)
group.add_argument(
"--core-rmsd-threshold",
type=float,
default=2.0,
metavar="A",
help="Reject when the mapped core's in-place RMSD exceeds this (default: %(default)s).",
)
group.add_argument(
"--ring-policy",
choices=("ring_system", "none"),
default="ring_system",
help="'ring_system' never leaves a ring half soft-core (default: %(default)s).",
)
group.add_argument(
"--charge-change-policy",
choices=("allow", "penalize", "reject"),
default="penalize",
help="How to treat a net charge change across an edge (default: %(default)s).",
)
[docs]
def add_network_arguments(parser: argparse.ArgumentParser) -> None:
"""Add the network-selection flags."""
group = parser.add_argument_group("network selection")
add_compat_argument(group) # type: ignore[arg-type]
group.add_argument("--planner", default="mst", help="Planner plugin (default: %(default)s).")
group.add_argument(
"--pair-strategy",
choices=("all_unordered_pairs", "all_pairs", "star", "linear", "explicit"),
default="all_unordered_pairs",
help="How candidate pairs are enumerated (default: %(default)s).",
)
group.add_argument("--hub", metavar="LIGAND", help="Hub ligand for star networks.")
group.add_argument("--n-edges", type=int, metavar="N", help="Cap on selected edges. Must allow a spanning network.")
group.add_argument(
"--edges-per-ligand",
type=int,
default=2,
metavar="N",
help="Target minimum degree per ligand (default: %(default)s).",
)
group.add_argument(
"--min-cycle-coverage",
type=float,
default=1.0,
metavar="F",
help="Target fraction of ligands lying on a cycle (default: %(default)s).",
)
group.add_argument("--forced-edge", action="append", metavar="A~B", help="Require this edge, repeatable.")
group.add_argument("--banned-edge", action="append", metavar="A~B", help="Forbid this edge, repeatable.")
group.add_argument("--explicit-edge", action="append", metavar="A~B", help="Edge for the 'explicit' strategy.")
group.add_argument(
"--allow-disconnected", action="store_true", help="Permit a network that does not span every ligand."
)
group.add_argument(
"--edge-direction",
choices=("fewer_softcore_first", "lexicographic", "heavier_second"),
default="fewer_softcore_first",
help="How selected edges are oriented (default: %(default)s).",
)
group.add_argument(
"--prefilter",
choices=("none", "fingerprint"),
default="none",
help="Similarity prefilter applied before mapping (default: %(default)s).",
)
group.add_argument("--prefilter-k", type=int, default=8, metavar="K", help="Neighbours kept per ligand.")
group.add_argument(
"--prefilter-min-tanimoto", type=float, default=0.4, metavar="F", help="Prefilter similarity floor."
)
group.add_argument(
"--selection-objective",
choices=("uniform_redundancy", "connectivity_then_cycles"),
default="uniform_redundancy",
help=(
"How redundancy is added after the spanning network is built (default: %(default)s). "
"'connectivity_then_cycles' prioritizes getting ligands onto at least one cycle."
),
)
group.add_argument(
"--cycle-coverage-mode",
choices=("node", "edge"),
default="node",
help=(
"What --min-cycle-coverage counts (default: %(default)s). 'node' is the fraction of "
"ligands lying on a cycle; 'edge' is the fraction of selected edges lying on one, which "
"at 1.0 means the network has no bridges at all. 'edge' is the stricter target."
),
)
group.add_argument(
"--max-cycle-size",
type=int,
metavar="N",
help="When improving cycle coverage, prefer cycles of at most N ligands.",
)
group.add_argument(
"--max-diameter",
type=int,
metavar="N",
help=(
"Target upper bound on the longest shortest path between two ligands, in edges. "
"Statistical error accumulates along a path; LOMAP caps this at 6 and FEP+ below 5. "
"Best-effort: a pool with no shortcut left to sell reports the shortfall rather than failing."
),
)
group.add_argument(
"--n-redundancy",
type=int,
default=2,
metavar="N",
help="Spanning trees overlaid by the 'redundant-mst' planner (default: %(default)s). Ignored by others.",
)
group.add_argument(
"--hub-selection",
choices=("most_partners", "min_total_cost"),
default="most_partners",
help=(
"How the 'star' planner picks a hub when --hub is not given (default: %(default)s). "
"'most_partners' ranks by feasible partner count, breaking ties on cost; 'min_total_cost' "
"ranks by summed cost, which is LOMAP's rule."
),
)
group.add_argument(
"--pair-evaluation",
choices=("eager", "adaptive"),
default="eager",
help=(
"Map all candidates up front, or expand fingerprint-ranked batches until network targets are met "
"(default: %(default)s)."
),
)
group.add_argument(
"--adaptive-initial-neighbors",
type=int,
default=3,
metavar="K",
help="Nearest neighbours mapped per ligand in the first adaptive batch (default: %(default)s).",
)
group.add_argument(
"--adaptive-batch-size",
type=int,
default=32,
metavar="N",
help="Pairs mapped per adaptive expansion (default: %(default)s).",
)
group.add_argument(
"--cbfe",
choices=("off", "bridge", "cycles", "all"),
default="off",
help=(
"Use counterpoised (CBFE) edges, which need no atom mapping and so are available between any "
"two ligands: 'bridge' only to join subnetworks no feasible RBFE edge can reach, 'cycles' also "
"to put ligands on a cycle, 'all' for an entirely counterpoised network (skips mapping "
"altogether). Default: %(default)s."
),
)
group.add_argument(
"--cbfe-base-cost",
type=float,
default=8.0,
metavar="COST",
help="Fixed cost of a CBFE edge, on the scorer's scale (default: %(default)s).",
)
group.add_argument(
"--cbfe-atom-weight",
type=float,
default=0.05,
metavar="W",
help="Added to the CBFE base cost per heavy atom, summed over both ligands (default: %(default)s).",
)
group.add_argument(
"--cluster-by",
choices=("none", "charge", "scaffold", "fingerprint"),
default="none",
help=(
"Partition the ligands and plan each cluster as its own subnetwork, joined by a few "
"chosen edges (default: %(default)s). The precision floor of a network goes as n ln n, "
"which is superlinear, so planning d clusters to the floor costs strictly less than "
"planning the whole set to it -- roughly 190 edges rather than 460 for 100 ligands in "
"five clusters. 'charge' groups on net formal charge, 'scaffold' on the Bemis-Murcko "
"framework, 'fingerprint' by average-linkage clustering on Tanimoto distance."
),
)
group.add_argument(
"--cluster-bridges",
type=int,
default=2,
metavar="N",
help=(
"Edges spent joining each pair of clusters, when --cluster-by is set (default: "
"%(default)s). Two puts the crossing itself on a cycle, which applies the "
"every-edge-in-a-cycle invariant to the least similar and least trustworthy edges in "
"the network; one gives the minimal join and leaves each crossing unchecked."
),
)
group.add_argument(
"--design",
choices=("none", "a_optimal", "d_optimal"),
default="none",
help=(
"Statistical design criterion for the 'optimal' planner (default: %(default)s). "
"'a_optimal' minimises the total variance of the estimates; 'd_optimal' minimises the "
"volume of their joint confidence ellipsoid and yields a markedly more cyclic network at "
"the same edge count -- prefer it when a cycle-closure correction will be applied "
"downstream. Requires --planner optimal; naming it with any other planner is refused "
"rather than ignored."
),
)
group.add_argument(
"--design-candidate-factor",
type=float,
default=3.0,
metavar="F",
help="Cap the design's candidate pool at F x the ligand count (default: %(default)s).",
)
group.add_argument(
"--design-refine",
action="store_true",
help=(
"Refine the design with a Fedorov exchange pass. Off by default: the heuristic is already "
"within a published 1.10x of the optimum, and this costs far more criterion evaluations."
),
)
group.add_argument(
"--design-total-ns",
type=float,
metavar="NS",
help=(
"Total simulation budget, in nanoseconds, distributed A-optimally across the selected "
"edges and written into each Amber .runconfig as a lambda-window and nanosecond budget. "
"Static: computed from predicted variances, never refitted against measured ones."
),
)
group.add_argument(
"--design-lambda-min",
type=int,
default=12,
metavar="N",
help="Fewest lambda windows the allocation may assign to an edge (default: %(default)s).",
)
group.add_argument(
"--design-lambda-max",
type=int,
default=24,
metavar="N",
help="Most lambda windows the allocation may assign to an edge (default: %(default)s).",
)
group.add_argument(
"--intermediates",
choices=("off", "bridge", "gaps"),
default="off",
help=(
"Invent bridging ligands for pairs no mapping can relate: 'bridge' only for pairs whose "
"endpoints fall in different components of the feasible pool, 'gaps' also for infeasible "
"pairs inside a component. The invented molecules are posed against their parents and their "
"sub-edges go through the same feasibility checks as any other edge; a proposal whose "
"sub-edges do not survive is dropped whole. Default: %(default)s."
),
)
group.add_argument(
"--intermediate-generator",
default="pairmap",
metavar="NAME",
help="Intermediate generator plugin (default: %(default)s). Constructed only when --intermediates is on.",
)
group.add_argument(
"--max-intermediates",
type=int,
metavar="N",
help="Cap on how many ligands one run may invent in total (default: no cap beyond the edge budget).",
)
group.add_argument(
"--max-intermediate-gaps",
type=int,
metavar="N",
help="Offer at most N gaps to the generator, most similar first (default: every gap).",
)
group.add_argument(
"--intermediates-per-gap",
type=int,
default=4,
metavar="N",
help="Cap on molecules proposed for one gap (default: %(default)s).",
)
group.add_argument(
"--intermediate-seed",
type=int,
default=0xF00D,
metavar="SEED",
help="Base RDKit seed for posing invented molecules (default: %(default)s).",
)
group.add_argument(
"--intermediate-pose-attempts",
type=int,
default=10,
metavar="N",
help="Embedding attempts spent posing one invented molecule (default: %(default)s).",
)
group.add_argument(
"--intermediate-pose-rmsd-factor",
type=float,
default=0.5,
metavar="F",
help=(
"Accept an invented pose only below F * --core-rmsd-threshold (default: %(default)s). The "
"sub-edges are gated on the full threshold anyway; this refuses a pose that would only just "
"scrape through."
),
)
group.add_argument(
"--intermediate-min-link-score",
type=float,
default=0.2,
metavar="S",
help=(
"Lowest link score a generator may consider worth proposing, on a (0, 1] similarity scale "
"(default: %(default)s). PairMap's MIN_SCORE."
),
)
group.add_argument(
"--intermediate-max-dist",
type=int,
default=3,
metavar="N",
help=(
"Longest source-to-target path, in links, a generator may propose (default: %(default)s). "
"At least 2: a one-link path is the direct edge that was already rejected. PairMap's MAX_DIST."
),
)
group.add_argument(
"--intermediate-max-cycle",
type=int,
default=4,
metavar="N",
help=(
"Largest cycle a generator may build to give a proposed link a second, independent route "
"(default: %(default)s). PairMap's MAX_CYCLE."
),
)
group.add_argument(
"--intermediate-max-subgraph-dist",
type=int,
default=4,
metavar="N",
help=(
"How far from either parent, in links, a molecule may sit and still join the proposed "
"subnetwork (default: %(default)s). PairMap's MAX_SUBGRAPH_DIST."
),
)
group.add_argument(
"--intermediate-beta",
type=float,
default=0.1,
metavar="B",
help=(
"Decay rate of the exponential link score, per heavy atom changed (default: %(default)s). "
"The same constant LOMAP's similarity uses."
),
)
group.add_argument(
"--progress",
action=argparse.BooleanOptionalAction,
default=None,
help="Show pair-mapping progress (default: enabled on an interactive terminal).",
)
group.add_argument(
"--consistency",
choices=("pairwise", "component", "graph"),
default="pairwise",
help=(
"Require each ligand to hold one common core -- and so one soft-core, and one Amber "
"scmask -- across its RBFE edges. 'component' asks for it within each connected "
"component of the RBFE network, 'graph' across the whole of it. CBFE edges are exempt "
"either way. Default: %(default)s."
),
)
group.add_argument(
"--jobs",
type=int,
default=1,
metavar="N",
help="Worker threads for pair mapping (default: %(default)s). Mapping is partly "
"pure Python, so speedup is sublinear and there is nothing to gain from setting "
"this above the CPU count. Each worker also holds its own MCS search, so peak "
"memory scales with this: roughly 40 MB per second of --mcs-timeout per job.",
)
[docs]
def build_alignment_options(args: argparse.Namespace) -> AlignmentOptions | None:
"""Assemble :class:`AlignmentOptions`, or ``None`` when ``--align`` was not given.
Returning ``None`` rather than an options object with alignment switched off keeps the
"not requested" case out of the library entirely: nothing downstream has to test for a
do-nothing method.
"""
method = getattr(args, "align", None)
if method is None:
return None
return AlignmentOptions(method=method, reference=args.align_reference, min_mcs_atoms=args.align_min_atoms)
[docs]
def build_mapping_options(args: argparse.Namespace) -> MappingOptions:
"""Assemble :class:`MappingOptions` from parsed arguments."""
return MappingOptions(
timeout=args.mcs_timeout,
match_selection=args.match_selection,
distance_threshold=args.distance_threshold,
core_pruning=CorePruningPolicy(),
)
[docs]
def build_network_options(args: argparse.Namespace) -> NetworkOptions:
"""Assemble :class:`NetworkOptions` from parsed arguments."""
softcore = SoftcorePolicy(
ring_policy=args.ring_policy,
max_softcore_atoms=args.max_softcore_atoms,
max_softcore_fraction=args.max_softcore_fraction,
min_core_atoms=args.min_core_atoms,
min_mcs_fraction=args.min_mcs_fraction,
core_rmsd_threshold=args.core_rmsd_threshold,
charge_change_policy=args.charge_change_policy,
)
intermediates = IntermediateOptions(
mode=args.intermediates,
generator=args.intermediate_generator,
max_intermediates=args.max_intermediates,
max_gaps=args.max_intermediate_gaps,
max_molecules=args.intermediates_per_gap,
seed=args.intermediate_seed,
max_pose_attempts=args.intermediate_pose_attempts,
pose_rmsd_factor=args.intermediate_pose_rmsd_factor,
min_link_score=args.intermediate_min_link_score,
max_dist=args.intermediate_max_dist,
max_cycle=args.intermediate_max_cycle,
max_subgraph_dist=args.intermediate_max_subgraph_dist,
beta=args.intermediate_beta,
)
return NetworkOptions(
pair_strategy=args.pair_strategy,
hub=args.hub,
explicit_pairs=tuple(args.explicit_edge or ()),
n_edges=args.n_edges,
edges_per_ligand=args.edges_per_ligand,
min_cycle_coverage=args.min_cycle_coverage,
forced_edges=tuple(args.forced_edge or ()),
banned_edges=tuple(args.banned_edge or ()),
require_connected=not args.allow_disconnected,
edge_direction=args.edge_direction,
prefilter=args.prefilter,
prefilter_k=args.prefilter_k,
prefilter_min_tanimoto=args.prefilter_min_tanimoto,
selection_objective=args.selection_objective,
cycle_coverage_mode=args.cycle_coverage_mode,
max_cycle_size=args.max_cycle_size,
max_diameter=args.max_diameter,
n_redundancy=args.n_redundancy,
hub_selection=args.hub_selection,
pair_evaluation=args.pair_evaluation,
adaptive_initial_neighbors=args.adaptive_initial_neighbors,
adaptive_batch_size=args.adaptive_batch_size,
show_progress=sys.stderr.isatty() if args.progress is None else args.progress,
jobs=args.jobs,
consistency=args.consistency,
cbfe_mode=args.cbfe,
cbfe_base_cost=args.cbfe_base_cost,
cbfe_atom_weight=args.cbfe_atom_weight,
cluster_by=args.cluster_by,
cluster_bridges=args.cluster_bridges,
design=args.design,
design_candidate_factor=args.design_candidate_factor,
design_refine=args.design_refine,
design_total_ns=args.design_total_ns,
design_lambda_min=args.design_lambda_min,
design_lambda_max=args.design_lambda_max,
softcore=softcore,
intermediates=intermediates,
compat=getattr(args, "compat", None),
)