feat(toolkit): add classification and migration

Implement the standard/non-standard/not-macrolactone classification layer
and integrate it into analyzer, fragmenter, and CLI outputs.

Port the remaining legacy package capabilities into new visualization and
workflow modules, restore batch/statistics/SDF scripts on top of the flat
CSV workflow, and update active docs to the new package API.
This commit is contained in:
2026-03-18 23:56:41 +08:00
parent 9ccbcfcd04
commit c0ead42384
24 changed files with 1497 additions and 313 deletions

View File

@@ -6,7 +6,12 @@ import sys
import pandas as pd
from .helpers import build_ambiguous_smiles, build_macrolactone
from .helpers import (
build_ambiguous_smiles,
build_macrolactone,
build_non_standard_ring_atom_macrolactone,
build_overlapping_candidate_macrolactone,
)
def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
@@ -24,7 +29,10 @@ def test_cli_smoke_commands():
analyze = run_cli("analyze", "--smiles", built.smiles)
assert analyze.returncode == 0, analyze.stderr
analyze_payload = json.loads(analyze.stdout)
assert analyze_payload["valid_ring_sizes"] == [16]
assert analyze_payload["classification"] == "standard_macrolactone"
assert analyze_payload["ring_size"] == 16
assert analyze_payload["primary_reason_code"] is None
assert analyze_payload["candidate_ring_sizes"] == [16]
number = run_cli("number", "--smiles", built.smiles)
assert number.returncode == 0, number.stderr
@@ -40,6 +48,55 @@ def test_cli_smoke_commands():
assert fragment_payload["fragments"][0]["fragment_smiles_labeled"]
def test_cli_analyze_reports_non_standard_classifications():
hetero = build_non_standard_ring_atom_macrolactone()
overlap = build_overlapping_candidate_macrolactone()
hetero_result = run_cli("analyze", "--smiles", hetero.smiles)
assert hetero_result.returncode == 0, hetero_result.stderr
hetero_payload = json.loads(hetero_result.stdout)
assert hetero_payload["classification"] == "non_standard_macrocycle"
assert hetero_payload["primary_reason_code"] == "contains_non_carbon_ring_atoms_outside_positions_1_2"
assert hetero_payload["ring_size"] == 16
overlap_result = run_cli("analyze", "--smiles", overlap.smiles)
assert overlap_result.returncode == 0, overlap_result.stderr
overlap_payload = json.loads(overlap_result.stdout)
assert overlap_payload["classification"] == "non_standard_macrocycle"
assert overlap_payload["primary_reason_code"] == "multiple_overlapping_macrocycle_candidates"
assert overlap_payload["ring_size"] == 12
def test_cli_analyze_csv_reports_classification_fields(tmp_path):
valid = build_macrolactone(14)
hetero = build_non_standard_ring_atom_macrolactone()
input_path = tmp_path / "molecules.csv"
output_path = tmp_path / "analysis.csv"
pd.DataFrame(
[
{"id": "valid_1", "smiles": valid.smiles},
{"id": "hetero_1", "smiles": hetero.smiles},
]
).to_csv(input_path, index=False)
completed = run_cli(
"analyze",
"--input",
str(input_path),
"--output",
str(output_path),
)
assert completed.returncode == 0, completed.stderr
analysis = pd.read_csv(output_path)
assert set(analysis["parent_id"]) == {"valid_1", "hetero_1"}
assert set(analysis["classification"]) == {"standard_macrolactone", "non_standard_macrocycle"}
assert "primary_reason_code" in analysis.columns
assert "ring_size" in analysis.columns
def test_cli_fragment_csv_skips_ambiguous_and_records_errors(tmp_path):
valid = build_macrolactone(14, {4: "methyl"})
ambiguous = build_ambiguous_smiles()