File size: 37,234 Bytes
48f7d9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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
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
146
147
148
149
150
151
152
153
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
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
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
292
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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
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
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
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
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
606
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
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
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
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
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
779
780
781
782
783
784
785
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
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
"""
LigParGen Hugging Face Space.

Mirrors the core of https://zarbi.chem.yale.edu/ligpargen/: SMILES input
(typed or drawn via Ketcher), PDB/MOL file upload, optimization level,
charge model, and a results zip with every output format the CLI already
produces -- plus a 3D preview of the optimized geometry the original site
doesn't have.

BOSS is fetched into this container at startup from a private HF Dataset
repo (see docs/adr/0003 in the main repo) -- never committed here, never
baked into the image.

Also runs as an MCP server (Gradio's built-in mcp_server=True) alongside
the web UI, exposing run_ligpargen() as an MCP tool at the same host's
/gradio_api/mcp/sse -- so an agent can submit a molecule and get back
parameter files the same way a human using the form does. That function's
type hints and docstring are what an MCP client sees as the tool's
argument schema and description, so keep them accurate if its signature
changes.
"""
import collections
import glob
import json
import multiprocessing
import os
import random
import shutil
import signal
import string
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal, Optional

import gradio as gr
from gradio_molecule3d import Molecule3D
from huggingface_hub import CommitScheduler, snapshot_download
from rdkit import Chem
from rdkit.Chem import AllChem, rdDetermineBonds
from rdkit.Geometry import Point3D

from LigParGen.Converter import convert

MAX_ATOMS = 200
BOSS_DIR = "/home/user/boss"
BOSS_REPO = os.environ.get("BOSS_ASSET_REPO", "lsdodda/ligpargen-boss-assets")

# A single BOSS run blocking the (concurrency_limit=1) queue indefinitely on
# a pathological input would stall every other visitor's job behind it --
# see run_convert_with_timeout().
JOB_TIMEOUT_S = int(os.environ.get("JOB_TIMEOUT_S", "60"))

# Basic per-client submission cap -- see check_rate_limit(). In-memory only
# (resets on restart/redeploy): acceptable for a single-worker Space with no
# other persistent state, and avoids standing up external infra for v1.
RATE_LIMIT_MAX = int(os.environ.get("RATE_LIMIT_MAX", "5"))
RATE_LIMIT_WINDOW_S = int(os.environ.get("RATE_LIMIT_WINDOW_S", "600"))
_rate_limit_lock = threading.Lock()
_submission_log = collections.defaultdict(list)

# Anonymous usage metrics -- job counts/sizes/outcomes only, never the
# submitted structure itself or any request/session identifier -- pushed
# periodically to a private HF Dataset. See setup_usage_scheduler().
USAGE_REPO = os.environ.get("USAGE_METRICS_REPO", "lsdodda/ligpargen-usage-metrics")
USAGE_LOG_DIR = Path("/tmp/usage_logs")
USAGE_LOG_FILE = USAGE_LOG_DIR / "usage.jsonl"
_usage_scheduler = None


def fetch_boss():
    """Pull BOSS from the private asset store into BOSS_DIR and fix permissions.

    HF's storage does not preserve the executable bit (confirmed empirically
    while provisioning the asset store), so every file needs chmod +x after
    the fetch -- a plain read-only volume mount can't do this, which is why
    this is a snapshot_download to a writable directory instead.
    """
    if os.path.isfile(os.path.join(BOSS_DIR, "BOSS")) and os.access(
        os.path.join(BOSS_DIR, "BOSS"), os.X_OK
    ):
        return  # already fetched (e.g. hot-reload during dev)

    token = os.environ.get("HF_TOKEN")
    if not token:
        raise RuntimeError(
            "HF_TOKEN secret is not set on this Space -- required to fetch "
            "BOSS from the private asset store. Set it in Space Settings."
        )

    os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "30")
    try:
        snapshot_download(
            repo_id=BOSS_REPO,
            repo_type="dataset",
            token=token,
            local_dir=BOSS_DIR,
        )
    except Exception as exc:  # noqa: BLE001 -- surface any failure loudly at startup
        raise RuntimeError(
            f"Failed to fetch BOSS from {BOSS_REPO}: {exc}"
        ) from exc

    for root, _dirs, files in os.walk(BOSS_DIR):
        for name in files:
            path = os.path.join(root, name)
            os.chmod(path, os.stat(path).st_mode | 0o111)

    boss_bin = os.path.join(BOSS_DIR, "BOSS")
    if not os.access(boss_bin, os.X_OK):
        raise RuntimeError(f"{boss_bin} still isn't executable after fetch+chmod")


def count_heavy_and_h_atoms(smiles=None, file_path=None):
    """Best-effort atom count (heavy + explicit/implicit H) for the 200-atom guard."""
    mol = None
    if smiles:
        mol = Chem.MolFromSmiles(smiles)
    elif file_path:
        if file_path.lower().endswith(".pdb"):
            mol = Chem.MolFromPDBFile(file_path, sanitize=False)
        else:
            mol = Chem.MolFromMolFile(file_path, sanitize=False)
    if mol is None:
        return None
    mol = Chem.AddHs(mol)
    return mol.GetNumAtoms()


def build_preview_sdf(pdb_path, resname, template_smiles=None):
    """Build an SDF (bond orders intact) from the final optimized PDB, for
    the 3D viewer.

    PDB has no bond-order field, so feeding Molecule3D the raw output PDB
    rendered every bond as an undifferentiated single stick, no visible
    double/triple/aromatic bonds -- gradio_molecule3d only accepts
    pdb/sdf/mol2/pdb1 (checked directly against its bundled JS), not a bare
    .mol, so SDF (a MOL block plus an optional data section RDKit's
    SDWriter already produces) is the right target format here, not MOL.

    Reuses the same AssignBondOrdersFromTemplate technique as
    LigParGen.mol_boss.convert_pdb2mol_with_smiles when a trusted SMILES is
    available (maps the final geometry's connectivity through known-correct
    bond orders), falling back to RDKit's geometry-based rdDetermineBonds
    when it isn't (a plain PDB upload with no accompanying SMILES).

    Returns the SDF path, or None if nothing could be built -- callers
    should fall back to the raw PDB preview in that case rather than show
    nothing.
    """
    try:
        pdb_mol = Chem.MolFromPDBFile(pdb_path, removeHs=False, sanitize=True)
        if pdb_mol is None:
            print("build_preview_sdf: MolFromPDBFile returned None for %r" % pdb_path)
            return None

        fixed = None
        if template_smiles:
            template = Chem.MolFromSmiles(template_smiles)
            if template is None:
                print("build_preview_sdf: MolFromSmiles(%r) returned None" % template_smiles)
            else:
                try:
                    fixed = AllChem.AssignBondOrdersFromTemplate(template, pdb_mol)
                except ValueError as exc:
                    print("build_preview_sdf: AssignBondOrdersFromTemplate failed (%s) -- "
                          "falling back to geometry-based perception" % exc)
                    fixed = None

        if fixed is None:
            rw = Chem.RWMol()
            conf = Chem.Conformer(pdb_mol.GetNumAtoms())
            pdb_conf = pdb_mol.GetConformer()
            for i, atom in enumerate(pdb_mol.GetAtoms()):
                rw.AddAtom(Chem.Atom(atom.GetSymbol()))
                pos = pdb_conf.GetAtomPosition(i)
                conf.SetAtomPosition(i, Point3D(pos.x, pos.y, pos.z))
            rw.AddConformer(conf, assignId=True)
            rdDetermineBonds.DetermineBonds(rw, charge=0, embedChiral=False)
            Chem.SanitizeMol(rw)
            fixed = rw

        sdf_path = os.path.join(os.path.dirname(pdb_path), "%s_preview.sdf" % resname)
        with Chem.SDWriter(sdf_path) as writer:
            writer.write(fixed)
        return sdf_path
    except Exception as exc:  # noqa: BLE001 -- never let a preview-only step break the job
        import traceback
        print("build_preview_sdf: failed for %r (resname=%s, template_smiles=%r): %s" % (
            pdb_path, resname, template_smiles, exc))
        traceback.print_exc()
        return None


def setup_usage_scheduler():
    """Set up anonymous usage-metrics logging (job counts/sizes/outcomes,
    never the submitted structure or any request/session identifier) via a
    CommitScheduler pushing to a private HF Dataset -- the same
    runtime-fetch/private-repo pattern already used for the BOSS asset
    store (docs/adr/0003). Returns None (and log_usage() becomes a no-op)
    if HF_TOKEN isn't set or the scheduler fails to start, so a
    misconfigured or rate-limited token disables logging rather than
    blocking the app from serving conversions at all -- usage tracking is
    a nice-to-have, not a hard dependency for the app's actual job."""
    token = os.environ.get("HF_TOKEN")
    if not token:
        print("HF_TOKEN not set -- usage metrics logging disabled.")
        return None
    try:
        return CommitScheduler(
            repo_id=USAGE_REPO,
            repo_type="dataset",
            folder_path=USAGE_LOG_DIR,
            path_in_repo="logs",
            every=5,
            token=token,
        )
    except Exception as exc:  # noqa: BLE001 -- logging is best-effort, never block startup
        print("Failed to set up usage metrics scheduler: %s" % exc)
        return None


def log_usage(input_type, n_atoms, opt_iters, lbcc, success, duration_s, error_type=None):
    if _usage_scheduler is None:
        return
    row = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "input_type": input_type,
        "n_atoms": n_atoms,
        "opt_iters": opt_iters,
        "lbcc": lbcc,
        "success": success,
        "error_type": error_type,
        "duration_s": round(duration_s, 2),
    }
    try:
        # CommitScheduler's own background thread reads this file on its
        # push interval -- .lock (a plain threading.Lock it exposes for
        # exactly this) keeps a partial line from ever being read mid-write.
        with _usage_scheduler.lock:
            with USAGE_LOG_FILE.open("a") as f:
                f.write(json.dumps(row) + "\n")
    except Exception as exc:  # noqa: BLE001 -- usage logging must never break a job
        print("log_usage: failed to write usage row: %s" % exc)


def check_rate_limit(client_ip):
    """True if this client is still under RATE_LIMIT_MAX submissions in the
    trailing RATE_LIMIT_WINDOW_S seconds (and records this attempt either
    way). In-memory sliding window, not persisted -- see RATE_LIMIT_MAX's
    module-level comment."""
    now = time.time()
    with _rate_limit_lock:
        recent = [t for t in _submission_log[client_ip] if now - t < RATE_LIMIT_WINDOW_S]
        recent.append(now)
        _submission_log[client_ip] = recent
        return len(recent) <= RATE_LIMIT_MAX


def _client_ip(request):
    """Best-effort real client IP behind HF Spaces' reverse proxy -- used
    only as a rate-limiting key, never logged or stored (see
    check_rate_limit()/log_usage())."""
    if request is None:
        return "unknown"
    try:
        forwarded = request.headers.get("x-forwarded-for")
        if forwarded:
            return forwarded.split(",")[0].strip()
        if request.client and request.client.host:
            return request.client.host
    except Exception:  # noqa: BLE001 -- IP extraction is best-effort, never block a submission over it
        pass
    return "unknown"


def _classify_input(smiles_text, upload_path):
    if upload_path:
        if upload_path.lower().endswith(".pdb"):
            return "pdb+smiles" if smiles_text else "pdb"
        return "mol"
    return "smiles"


def _run_convert_in_child(kwargs, job_dir, result_queue):
    os.setsid()  # new process group -- lets the parent kill BOSS's whole
                 # subprocess tree on timeout, not just this one process.
    try:
        os.chdir(job_dir)
        convert(**kwargs)
        result_queue.put(("ok", None, None))
    except ValueError as exc:
        result_queue.put(("value_error", str(exc), None))
    except Exception as exc:  # noqa: BLE001 -- serialize any failure back to the parent
        result_queue.put(("error", str(exc), type(exc).__name__))


def run_convert_with_timeout(kwargs, job_dir, timeout_s):
    """Run LigParGen.Converter.convert() in a child process, killing its
    whole process group if it doesn't finish within timeout_s.

    convert() ultimately drives BOSS through blocking os.system() calls
    with no timeout of their own (LigParGen/BOSSReader.py's Get_OPT) -- a
    pathological input hanging BOSS would otherwise stall this Space's
    single-worker queue (concurrency_limit=1) for every other visitor
    indefinitely. Running convert() in its own process (started as its own
    session/process group via os.setsid() in the child) means a timeout can
    actually reclaim it: os.killpg() reaches BOSS's csh/xZCM1A/etc.
    descendants too, not just the immediate Python child, which a plain
    thread-based timeout could never do (Python threads can't be killed,
    and terminating just the child process would orphan BOSS's own
    subprocesses to keep running).
    """
    ctx = multiprocessing.get_context("fork")
    result_queue = ctx.Queue()
    proc = ctx.Process(target=_run_convert_in_child, args=(kwargs, job_dir, result_queue), daemon=True)
    proc.start()
    proc.join(timeout_s)

    if proc.is_alive():
        try:
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
        except ProcessLookupError:
            pass
        proc.join(5)
        raise gr.Error(
            "This molecule took longer than %d minutes to process and was stopped -- "
            "it may be too large or complex for this Space. Try a smaller molecule or "
            "fewer optimization iterations." % (timeout_s // 60)
        )

    if result_queue.empty():
        raise gr.Error(
            "LigParGen's process exited unexpectedly (exit code %s). Please try again "
            "or open an issue on GitHub." % proc.exitcode
        )

    status, msg, exc_type = result_queue.get()
    if status == "value_error":
        raise gr.Error(msg)
    if status == "error":
        raise gr.Error(
            "LigParGen failed while processing this molecule (%s). If this keeps "
            "happening, please open an issue on GitHub." % (exc_type or "unknown error")
        )


def run_ligpargen(
    smiles_text: str,
    upload_file: Optional[str],
    opt_iters: Literal["0", "1", "2", "3"],
    charge_model: Literal[
        "1.14*CM1A-LBCC (neutral molecules)",
        "1.14*CM1A (neutral or charged)",
    ],
    charge: Literal["0", "-1", "-2", "1", "2"],
    request: gr.Request,
    progress=gr.Progress(),
) -> tuple:
    """Generate OPLS-AA/CM1A force-field parameters for an organic ligand.

    Runs BOSS + LigParGen on a molecule and returns a zip containing
    parameter/topology files for OpenMM, GROMACS, CHARMM, LAMMPS, TINKER,
    CNS/X-PLOR, Q, DESMOND, and BOSS/MCPRO, plus a PQR file and a 3D
    preview of the optimized geometry. Molecules are limited to 200 atoms
    (heavy atoms plus hydrogens); a single job is stopped if it runs
    longer than about a minute, and submissions from the same client are
    rate-limited.

    Args:
        smiles_text: SMILES string for the molecule, e.g. "c1ccccc1" for
            benzene. Required unless upload_file is a MOL file. Can be
            combined with a PDB upload_file, in which case it is used as
            a trusted template to fix that PDB's bond orders and add any
            missing hydrogens.
        upload_file: Path or URL to a PDB or MOL file to convert instead
            of (or, for a PDB, alongside) smiles_text. MOL files must
            already include all hydrogens explicitly. Leave unset to
            submit SMILES only.
        opt_iters: Number of BOSS geometry-optimization iterations to
            run, as a string integer from "0" (no optimization) to "3".
        charge_model: Partial-charge scheme to use. "1.14*CM1A-LBCC
            (neutral molecules)" only supports a neutral molecule --
            charge is forced to "0" regardless of the charge argument.
            "1.14*CM1A (neutral or charged)" supports any of the charge
            values below.
        charge: Net formal charge of the molecule, as a string integer.
            Ignored (forced to "0") when charge_model is the LBCC scheme.

    Returns:
        A 3-tuple: path to a .zip file with all output formats, path to
        an SDF or PDB file of the optimized geometry for 3D preview (or
        None if a preview could not be built), and a short human-readable
        status message.
    """
    start_time = time.perf_counter()
    smiles_text = (smiles_text or "").strip()
    upload_path = upload_file if upload_file is not None else None

    # Every submission attempt counts toward the rate limit, checked first --
    # cheapest possible gate, before spending any RDKit/BOSS work on it.
    if not check_rate_limit(_client_ip(request)):
        raise gr.Error(
            "Rate limit reached (%d submissions per %d minutes). Please wait "
            "before submitting again." % (RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_S // 60)
        )

    if not smiles_text and not upload_path:
        raise gr.Error("Provide a SMILES string (typed or drawn) or upload a PDB/MOL file.")
    if smiles_text and upload_path and not upload_path.lower().endswith(".pdb"):
        raise gr.Error(
            "SMILES + upload is only supported for PDB uploads (used to fix bond "
            "orders/missing Hs) -- submit either SMILES or a MOL file, not both."
        )

    input_type = _classify_input(smiles_text, upload_path)

    # Prefer the SMILES for the atom count when both are given: PDB uploads are
    # often missing hydrogens, which would undercount against MAX_ATOMS.
    n_atoms = count_heavy_and_h_atoms(smiles=smiles_text or None, file_path=None if smiles_text else upload_path)
    if n_atoms is not None and n_atoms > MAX_ATOMS:
        log_usage(input_type, n_atoms, int(opt_iters), None, success=False,
                  duration_s=time.perf_counter() - start_time, error_type="TooManyAtoms")
        raise gr.Error(f"Molecule has {n_atoms} atoms; maximum allowed is {MAX_ATOMS}.")

    lbcc = charge_model == "1.14*CM1A-LBCC (neutral molecules)"
    resolved_charge = 0 if lbcc else int(charge)

    # PDB's resName field is a strict 3-character fixed-width column (spec cols
    # 18-20) -- LigParGen's own PDB/GRO/etc. writers use fixed-width formatting
    # too, so anything longer silently overflows into the chainID/resSeq/coordinate
    # columns downstream. Confirmed live: an 8-char resname ("LPG"+5 hex chars)
    # shifted every coordinate column and produced a non-numeric resSeq, which
    # RDKit's own PDB parser (MolFromPDBFile) outright rejected and PyMOL misread.
    # 3 random letters -- matches the CLI's own convention ("-r, should be a 3
    # LETTER WORD") and gives 26**3 = 17,576 combinations, plenty for this app's
    # single-worker, serialized-queue usage.
    resname = "".join(random.choices(string.ascii_uppercase, k=3))
    job_dir = tempfile.mkdtemp(prefix="ligpargen_")

    # Trusted SMILES for the 3D preview's bond-order fix (build_preview_sdf) --
    # separate from kwargs["smiles"], which only LigParGen.Converter.convert()
    # itself uses (and only for the PDB-upload case, to fix the actual BOSS
    # input). Typed/drawn SMILES and PDB+SMILES both already have one; a plain
    # MOL upload already carries correct bond orders of its own, so derive an
    # equivalent SMILES from it too, rather than leaving the preview to the
    # geometry-only fallback when a perfectly good source of truth exists.
    preview_template_smiles = smiles_text or None

    kwargs = dict(opt=int(opt_iters), charge=resolved_charge, lbcc=lbcc, resname=resname)
    if upload_path:
        staged = os.path.join(job_dir, os.path.basename(upload_path))
        shutil.copyfile(upload_path, staged)
        if upload_path.lower().endswith(".pdb"):
            kwargs["pdb"] = os.path.basename(staged)
            # A SMILES supplied alongside the PDB is used as a trusted template
            # to fix connectivity/bond orders/missing Hs -- see
            # LigParGen.mol_boss.convert_pdb2mol_with_smiles.
            if smiles_text:
                kwargs["smiles"] = smiles_text
        else:
            kwargs["mol"] = os.path.basename(staged)
            if preview_template_smiles is None:
                mol_for_smiles = Chem.MolFromMolFile(staged, sanitize=False)
                if mol_for_smiles is not None:
                    try:
                        Chem.SanitizeMol(mol_for_smiles)
                        preview_template_smiles = Chem.MolToSmiles(mol_for_smiles)
                    except Exception:  # noqa: BLE001 -- preview-only; geometry fallback still applies
                        pass
    else:
        kwargs["smiles"] = smiles_text

    progress(0.1, desc="Running BOSS + LigParGen...")
    try:
        # Runs convert() in its own killable process group -- see
        # run_convert_with_timeout()'s docstring for why a plain in-process
        # call (the prior behavior) can't be safely bounded by a timeout.
        run_convert_with_timeout(kwargs, job_dir, JOB_TIMEOUT_S)

        zip_path = os.path.join(job_dir, f"{resname}.zip")
        if not os.path.isfile(zip_path):
            raise gr.Error("LigParGen did not produce output -- check the molecule is valid.")

        preview_pdb_candidates = glob.glob(f"/tmp/{resname}.pdb")
        preview_pdb = preview_pdb_candidates[0] if preview_pdb_candidates else None

        preview_file = preview_pdb
        if preview_pdb:
            preview_sdf = build_preview_sdf(preview_pdb, resname, template_smiles=preview_template_smiles)
            if preview_sdf:
                preview_file = preview_sdf
    except gr.Error as exc:
        log_usage(input_type, n_atoms, int(opt_iters), lbcc, success=False,
                  duration_s=time.perf_counter() - start_time, error_type=str(exc))
        raise
    except Exception as exc:  # noqa: BLE001 -- never let an unanticipated error surface raw internals
        log_usage(input_type, n_atoms, int(opt_iters), lbcc, success=False,
                  duration_s=time.perf_counter() - start_time, error_type=type(exc).__name__)
        raise gr.Error("An unexpected error occurred. Please try again, or open an issue on GitHub if it persists.")

    log_usage(input_type, n_atoms, int(opt_iters), lbcc, success=True,
              duration_s=time.perf_counter() - start_time)
    progress(1.0, desc="Done")
    return zip_path, preview_file, f"Done -- {resname}"


KETCHER_HTML = """
<div id="loading" style="display:flex;justify-content:center;align-items:center;height:420px">
<p style="color:#8a8272;font-size:0.9rem;font-family:'IBM Plex Mono',ui-monospace,monospace">loading structure editor&hellip;</p>
</div>
<div id="root" style="height:420px;border:1px solid #e7e1d3;border-radius:10px;overflow:hidden"></div>
<button id="ketcher-use-btn" type="button"
        style="width:100%;margin-top:10px;padding:10px 16px;border-radius:8px;border:1px solid #4f46e5;
               background:#4f46e5;color:#fff;font-family:inherit;font-weight:600;font-size:0.92rem;
               cursor:pointer;transition:background 0.15s ease,border-color 0.15s ease">
  Use drawn structure
</button>
"""

# Ketcher 2.7.2, pinned per docs/research/ketcher-and-molecule3d-integration.md --
# newer versions have a known getSmiles() regression on some structures.
#
# The "Use drawn structure" button lives inside this HTML block as a plain
# <button>, wired here via direct DOM manipulation rather than Gradio's
# js=/fn=None event-return mechanism -- that mechanism did not reliably fire
# on real user clicks in this Gradio version (reproduced live: getSmiles()
# worked when called from the console, but neither a real click nor a
# programmatic .click() on a Gradio-wired button ever invoked it, per
# instrumentation that counted calls to a wrapped getSmiles()). Writing
# straight to the target textbox's <textarea> and dispatching a native
# "input" event is the standard, robust way to feed a Gradio-bound
# component from custom JS.
KETCHER_LOAD_JS = """
async () => {
  let url = "https://huggingface.co/datasets/simonduerr/ketcher-2.7.2/raw/main/static/css/main.6a646761.css";
  fetch(url).then(r => r.text()).then(text => {
    const style = document.createElement('style');
    style.textContent = text;
    document.head.appendChild(style);
  });
  url = "https://huggingface.co/datasets/simonduerr/ketcher-2.7.2/resolve/main/static/js/main.5445f351.js";
  fetch(url).then(r => r.text()).then(text => {
    const script = document.createElement('script');
    script.src = URL.createObjectURL(new Blob([text], { type: 'application/javascript' }));
    document.head.appendChild(script);
    document.getElementById('loading').style.display = 'none';
  });

  document.getElementById('ketcher-use-btn').addEventListener('click', async () => {
    const smi = await ketcher.getSmiles();
    const textarea = document.querySelector('#smiles_box textarea');
    const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
    setter.call(textarea, smi);
    textarea.dispatchEvent(new Event('input', { bubbles: true }));
  });
}
"""

# Gradio defaults to the visitor's OS/browser color-scheme preference, which
# put this page in dark mode for anyone with dark mode on. That broke Ketcher
# specifically: its toolbar SVG icons use `fill: currentColor` with no color
# of their own, so they inherited Gradio's near-white dark-mode body text
# color and rendered almost invisible against Ketcher's own light toolbar
# chrome (confirmed live: computed icon color rgb(244,244,245) on a white
# toolbar). Force light mode via Gradio's built-in `__theme` query param --
# simpler and more robust than chasing every inherited color it broke.
FORCE_LIGHT_JS = """
() => {
  const url = new URL(window.location);
  if (url.searchParams.get('__theme') !== 'light') {
    url.searchParams.set('__theme', 'light');
    window.location.replace(url.href);
  }
}
"""

THEME = gr.themes.Base(
    primary_hue=gr.themes.colors.indigo,
    secondary_hue=gr.themes.colors.teal,
    neutral_hue=gr.themes.colors.stone,
    font=[gr.themes.GoogleFont("Public Sans"), "ui-sans-serif", "system-ui", "sans-serif"],
    font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "SFMono-Regular", "monospace"],
).set(
    body_background_fill="*neutral_50",
    background_fill_primary="white",
    block_background_fill="white",
    block_border_color="*neutral_200",
    block_label_text_color="*neutral_500",
    block_title_text_color="*neutral_800",
    body_text_color="*neutral_800",
    body_text_color_subdued="*neutral_500",
    border_color_primary="*neutral_200",
    button_primary_background_fill="*primary_600",
    button_primary_background_fill_hover="*primary_700",
    button_primary_text_color="white",
    button_secondary_background_fill="white",
    button_secondary_background_fill_hover="*neutral_50",
    button_secondary_border_color="*neutral_300",
    button_secondary_text_color="*neutral_800",
    input_background_fill="white",
    input_border_color="*neutral_300",
    input_border_color_focus="*primary_500",
)

CSS = """
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,wght@0,500;0,600;1,500&display=swap');

#app-header .app-kicker {
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--body-text-color-subdued);
  margin: 0 0 0.35rem 0;
}
#app-header h1 {
  font-family: 'Newsreader', ui-serif, Georgia, serif;
  font-style: italic;
  font-weight: 500;
  font-size: 2.4rem;
  letter-spacing: -0.01em;
  margin: 0;
}
.app-subtitle { max-width: 46rem; }
.app-subtitle p {
  color: var(--body-text-color-subdued);
  font-size: 1rem;
  line-height: 1.6;
}
.about-panel {
  max-width: 46rem;
  margin-top: 0.7rem;
  border: 1px solid var(--border-color-primary);
  border-radius: var(--radius-lg);
  background: var(--background-fill-primary);
  overflow: hidden;
}
.about-panel summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.7rem 1rem;
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--body-text-color-subdued);
  user-select: none;
}
.about-panel summary::-webkit-details-marker { display: none; }
.about-panel summary:hover { color: var(--body-text-color); }
.about-panel .about-chevron {
  font-family: ui-sans-serif, sans-serif;
  font-size: 0.85rem;
  transition: transform 0.15s ease;
}
.about-panel[open] .about-chevron { transform: rotate(180deg); }
.about-panel .about-body {
  margin: 0;
  padding: 0 1.1rem 0.9rem 1.1rem;
  border-top: 1px solid var(--border-color-primary);
  padding-top: 0.85rem;
  color: var(--body-text-color-subdued);
  font-size: 0.88rem;
  line-height: 1.65;
}
.about-panel .about-body a {
  color: var(--body-text-color-subdued);
  text-decoration: underline;
  text-decoration-color: var(--border-color-primary);
}
.about-panel .about-body a:hover { color: var(--primary-600); text-decoration-color: var(--primary-600); }
.section-label p {
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: 1.05rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--body-text-color-subdued);
  border-bottom: 1px solid var(--border-color-primary);
  padding-bottom: 0.6rem;
  margin-bottom: 0 !important;
}
.panel {
  border-radius: var(--radius-lg) !important;
  padding: 1.1rem !important;
}
#smiles_box textarea, #status_box textarea {
  font-family: 'IBM Plex Mono', ui-monospace, monospace !important;
}
.citations ol {
  padding-left: 1.2rem;
  margin: 0;
}
.citations li {
  color: var(--body-text-color-subdued);
  font-size: 0.85rem;
  line-height: 1.55;
  margin-bottom: 0.85rem;
}
.citations li:last-child { margin-bottom: 0; }
.citations li strong { color: var(--body-text-color); font-weight: 500; }
.citations a {
  color: var(--body-text-color-subdued);
  text-decoration: underline;
  text-decoration-color: var(--border-color-primary);
}
.citations a:hover { color: var(--primary-600); text-decoration-color: var(--primary-600); }

/* Belt-and-braces alongside FORCE_LIGHT_JS above: pin Ketcher's own DOM to
   light regardless of the page's color scheme, since its bundle assumes a
   light host and inherited color is otherwise how it broke in the first
   place. */
#ketcher-panel, #ketcher-panel * { color: #1a1a1a; }
#ketcher-panel { color-scheme: light; }
#ketcher-panel #ketcher-use-btn:hover { background: #4338ca !important; border-color: #4338ca !important; }
"""


# Same "about" text as the original Yale webserver, with the CLI/issues
# links pointed at this repo instead of the original site's own pages.
# Collapsed by default (a <details> disclosure, not gr.Markdown) so this
# secondary text doesn't compete with the hero header or push Step 1 below
# the fold -- expand on demand instead.
ABOUT_HTML = """
<details class="about-panel">
  <summary>
    <span>About LigParGen</span>
    <span class="about-chevron">&#9662;</span>
  </summary>
  <p class="about-body">
    LigParGen is a web-based service that provides force field (FF)
    parameters for organic molecules or ligands, offered by the Jorgensen
    group. LigParGen provides bond, angle, dihedral, and Lennard-Jones
    OPLS-AA parameters with 1.14*CM1A or 1.14*CM1A-LBCC partial atomic
    charges. Server provides parameter and topology files for commonly
    used molecular dynamics and Monte Carlo packages OpenMM, Gromacs,
    NAMD, CHARMM, LAMMPS, TINKER, CNS/X-PLOR, Q, DESMOND, BOSS and MCPRO.
    Also, the PQR file is generated. Supported input formats: SMILES, MOL
    and PDB. Maximum ligand size allowed is 200 atoms. Check
    <a href="https://github.com/leelasd/LigParGen_2.3" target="_blank" rel="noopener">this link</a>
    to use LigParGen software from command-line in your local computer.
    Please, report any issue on the
    <a href="https://github.com/leelasd/LigParGen_2.3/issues" target="_blank" rel="noopener">LigParGen issues</a>
    page.
  </p>
</details>
"""


# Same citations as the CLI's own --help text (LigParGen/Converter.py) plus
# the core OPLS-AA potential paper, reproduced here for the web UI in place
# of the original webserver's References section.
REFERENCES_MD = """
1. Dodda, L. S.; Cabeza de Vaca, I.; Tirado-Rives, J.; Jorgensen, W. L.
   **LigParGen web server: an automatic OPLS-AA parameter generator for
   organic ligands.** *Nucleic Acids Res.* **2017**, *45* (W1), W331-W336.
   [doi:10.1093/nar/gkx312](https://doi.org/10.1093/nar/gkx312)
2. Dodda, L. S.; Vilseck, J. Z.; Tirado-Rives, J.; Jorgensen, W. L.
   **1.14\\*CM1A-LBCC: Localized Bond-Charge Corrected CM1A Charges for
   Condensed-Phase Simulations.** *J. Phys. Chem. B* **2017**, *121* (15),
   3864-3870. [doi:10.1021/acs.jpcb.7b00272](https://doi.org/10.1021/acs.jpcb.7b00272)
3. Udier-Blagovic, M.; Morales De Tirado, P.; Pearlman, S. A.; Jorgensen, W. L.
   **Accuracy of free energies of hydration using CM1 and CM3 atomic
   charges.** *J. Comput. Chem.* **2004**, *25*, 1322-1332.
   [doi:10.1002/jcc.20059](https://doi.org/10.1002/jcc.20059)
4. Jorgensen, W. L.; Tirado-Rives, J.
   **Potential energy functions for atomic-level simulations of water and
   organic and biomolecular systems.** *Proc. Natl. Acad. Sci. USA* **2005**,
   *102*, 6665-6670.
   [doi:10.1073/pnas.0408037102](https://doi.org/10.1073/pnas.0408037102)
"""


def build_ui():
    with gr.Blocks(title="LigParGen", theme=THEME, css=CSS) as demo:
        gr.Markdown('<p class="app-kicker">OPLS-AA / CM1A &middot; BOSS</p>\n\n# LigParGen', elem_id="app-header")
        gr.Markdown(
            "OPLS-AA/CM1A force-field parameter generator for organic ligands.",
            elem_classes="app-subtitle",
        )
        gr.HTML(ABOUT_HTML)

        with gr.Row():
            with gr.Column():
                gr.Markdown("Step 1 &mdash; Input structure", elem_classes="section-label")
                with gr.Group(elem_classes="panel"):
                    smiles_box = gr.Textbox(
                        label="SMILES", placeholder="Enter SMILES, e.g. c1ccccc1", elem_id="smiles_box"
                    )
                    gr.Button("Sample: Benzene", size="sm").click(
                        lambda: "c1ccccc1", inputs=None, outputs=smiles_box, api_name=False
                    )

                    gr.Markdown("**Or draw a structure:**")
                    ketcher_html = gr.HTML(KETCHER_HTML, elem_id="ketcher-panel")

                    gr.Markdown(
                        "**Or upload a MOL/PDB file:** MOL files must include all "
                        "hydrogens. For a PDB upload, also supply the SMILES above "
                        "-- it's used to fix bond orders and add any missing "
                        "hydrogens (PDB files don't encode bond order and are "
                        "often missing Hs)."
                    )
                    upload = gr.File(label="MOL or PDB file", file_types=[".mol", ".pdb"])

                gr.Markdown("Step 2 &mdash; Options", elem_classes="section-label")
                with gr.Group(elem_classes="panel"):
                    opt_iters = gr.Dropdown(["0", "1", "2", "3"], value="0", label="Molecule optimization iterations")
                    charge_model = gr.Radio(
                        ["1.14*CM1A-LBCC (neutral molecules)", "1.14*CM1A (neutral or charged)"],
                        value="1.14*CM1A-LBCC (neutral molecules)",
                        label="Charge model",
                    )
                    charge = gr.Dropdown(["0", "-1", "-2", "1", "2"], value="0", label="Molecule charge")

                    submit = gr.Button("Submit Molecule", variant="primary")

            with gr.Column():
                gr.Markdown("Results", elem_classes="section-label")
                with gr.Group(elem_classes="panel"):
                    status = gr.Textbox(label="Status", interactive=False, elem_id="status_box")
                    output_zip = gr.File(label="Download all output formats (.zip)")
                    preview = Molecule3D(label="3D preview (optimized geometry)", reps=[{"style": "stick"}])

                gr.Markdown("References", elem_classes="section-label")
                gr.Markdown(REFERENCES_MD, elem_classes="citations")

        submit.click(
            run_ligpargen,
            inputs=[smiles_box, upload, opt_iters, charge_model, charge],
            outputs=[output_zip, preview, status],
            api_name="run_ligpargen",
        )

        demo.load(fn=None, inputs=None, outputs=None, js=FORCE_LIGHT_JS)
        demo.load(fn=None, inputs=None, outputs=None, js=KETCHER_LOAD_JS)

    demo.queue(default_concurrency_limit=1)
    return demo


if __name__ == "__main__":
    fetch_boss()
    os.environ["BOSSdir"] = BOSS_DIR
    _usage_scheduler = setup_usage_scheduler()
    build_ui().launch(server_name="0.0.0.0", server_port=7860, mcp_server=True)