moorlight 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moorlight-0.1.0/LICENSE +21 -0
- moorlight-0.1.0/PKG-INFO +70 -0
- moorlight-0.1.0/README.md +44 -0
- moorlight-0.1.0/moorlight/__init__.py +3 -0
- moorlight-0.1.0/moorlight/__main__.py +110 -0
- moorlight-0.1.0/moorlight/chat.py +175 -0
- moorlight-0.1.0/moorlight/dicts.py +63 -0
- moorlight-0.1.0/moorlight/edit.py +630 -0
- moorlight-0.1.0/moorlight/maps.py +243 -0
- moorlight-0.1.0/moorlight/mcp.py +110 -0
- moorlight-0.1.0/moorlight/model.py +247 -0
- moorlight-0.1.0/moorlight/rsr.py +642 -0
- moorlight-0.1.0/moorlight/server.py +188 -0
- moorlight-0.1.0/moorlight/session.py +858 -0
- moorlight-0.1.0/moorlight/web/__init__.py +1 -0
- moorlight-0.1.0/moorlight/web/app.js +1194 -0
- moorlight-0.1.0/moorlight/web/index.html +138 -0
- moorlight-0.1.0/moorlight/web/style.css +86 -0
- moorlight-0.1.0/moorlight.egg-info/PKG-INFO +70 -0
- moorlight-0.1.0/moorlight.egg-info/SOURCES.txt +24 -0
- moorlight-0.1.0/moorlight.egg-info/dependency_links.txt +1 -0
- moorlight-0.1.0/moorlight.egg-info/entry_points.txt +3 -0
- moorlight-0.1.0/moorlight.egg-info/requires.txt +7 -0
- moorlight-0.1.0/moorlight.egg-info/top_level.txt +1 -0
- moorlight-0.1.0/pyproject.toml +36 -0
- moorlight-0.1.0/setup.cfg +4 -0
moorlight-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tobias Weinert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
moorlight-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moorlight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight Coot-like model building in the browser, with TorchRef real-space refinement and Claude integration
|
|
5
|
+
Author: Tobias Weinert
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MotivatedScientist/moorlight
|
|
8
|
+
Project-URL: Issues, https://github.com/MotivatedScientist/moorlight/issues
|
|
9
|
+
Keywords: crystallography,model building,coot,torchref,real-space refinement,time-resolved
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: gemmi>=0.7
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: scipy
|
|
21
|
+
Requires-Dist: scikit-image
|
|
22
|
+
Requires-Dist: aiohttp
|
|
23
|
+
Requires-Dist: torch
|
|
24
|
+
Requires-Dist: torchref
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Moorlight
|
|
28
|
+
|
|
29
|
+
A small Coot 0.9-style model-building viewer that runs in the browser, with real-space refinement
|
|
30
|
+
built on TorchRef restraints and Claude built in. Made for TorchRef (difference) refinement results.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install moorlight # pulls in TorchRef, gemmi, torch (CPU is fine)
|
|
34
|
+
moorlight path/to/torchref/run/ # refined.pdb + refined.mtz, or D.pdb + L_refined.pdb + difference_data.mtz
|
|
35
|
+
moorlight model.pdb maps.mtz lig.cif # or individual files
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What it does
|
|
39
|
+
|
|
40
|
+
- **Maps.** It recognises TorchRef columns automatically:
|
|
41
|
+
- `FWT/PHWT` and `DELFWT/PHDELWT` from single-dataset refinement
|
|
42
|
+
- `WDF`, `DF` and `DFc` with `PHIC_dark`, `mDFop-DFc/PHIC_diff`, and the extrapolated maps (with `PHIC_light`) from difference refinement
|
|
43
|
+
- other `F/PHI` column pairs, or CCP4 maps
|
|
44
|
+
|
|
45
|
+
Maps are drawn as Coot-style chicken wire within a 10 Å radius of the centre. The scroll wheel changes the level of the map marked ●.
|
|
46
|
+
- **Real-space refinement.** Choose a zone, the residue ±1 (R), a sphere (Shift-R), or a new alternative conformer. Each refinement builds TorchRef restraints (bonds, angles, torsions, planes, chirals; ligand CIFs included) for the moving residues plus a fixed shell around them. These are minimised against the refinement map (the one marked **R**) with live updates in the view. Drag an atom to pull it; Enter accepts, Esc rejects. Fixed atoms never move.
|
|
47
|
+
- **Difference target.** Set the refinement target to *difference* (or *map + difference*). Moving atoms are then fitted so that the model's light-minus-dark density matches a difference map (WDF by default), with a fitted scale. The dark reference is D.pdb for TorchRef difference refinement, otherwise the residue's other alternative conformers, otherwise the starting coordinates. The banner shows the live difference CC.
|
|
48
|
+
- **Repulsion.** Non-bonded repulsion is off by default, so you can pull atoms on top of each other. Tick *atoms repel* to switch it on.
|
|
49
|
+
- **Building.** Alternative conformers, rotamers ranked by density, mutate, pepflip, adding residues at either terminus, renumbering, deleting, waters (W, or *Find water sites*), ions, ligands from a CIF that contains coordinates, occupancy/B, undo/redo, save.
|
|
50
|
+
- **Peaks tab.** Lists the strongest ± difference peaks with their nearest atom, after symmetry mapping. Click a peak to go there.
|
|
51
|
+
- **Claude tab.** A chat running headless Claude Code with every viewer command as a tool. Each message carries your current view (visible maps and levels, centre, selected atoms), so "this map" means what you're looking at. Claude can take screenshots of the view. The *shell* box lets it run commands such as TorchRef refinements; it's off by default.
|
|
52
|
+
|
|
53
|
+
Press `?` in the viewer for keys and mouse controls.
|
|
54
|
+
|
|
55
|
+
## Using it from any Claude Code session (MCP)
|
|
56
|
+
|
|
57
|
+
The viewer writes its address to `~/.moorlight/server.json`. To attach the tools to any Claude Code session:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
claude mcp add moorlight -- python -m moorlight.mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Claude can then call `state`, `screenshot`, `goto`, `set_map`, `peaks`, `map_correlation`, `refine`, `add_altconf` and the other commands directly.
|
|
64
|
+
|
|
65
|
+
## Notes / limits (v0.1)
|
|
66
|
+
|
|
67
|
+
- Real-space refinement uses trilinear interpolation of the map and a simple non-bonded repulsion. Ramachandran restraints are not used yet.
|
|
68
|
+
- Hydrogens are left out of refinement and follow their parent atom afterwards.
|
|
69
|
+
- Ligands need coordinates in the CIF (from acedrg, grade or eLBOW). There's no RDKit conformer generation yet.
|
|
70
|
+
- Rotamers come from the Penultimate library (modal χ values), ranked by mean density at the side-chain atoms.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Moorlight
|
|
2
|
+
|
|
3
|
+
A small Coot 0.9-style model-building viewer that runs in the browser, with real-space refinement
|
|
4
|
+
built on TorchRef restraints and Claude built in. Made for TorchRef (difference) refinement results.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install moorlight # pulls in TorchRef, gemmi, torch (CPU is fine)
|
|
8
|
+
moorlight path/to/torchref/run/ # refined.pdb + refined.mtz, or D.pdb + L_refined.pdb + difference_data.mtz
|
|
9
|
+
moorlight model.pdb maps.mtz lig.cif # or individual files
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
- **Maps.** It recognises TorchRef columns automatically:
|
|
15
|
+
- `FWT/PHWT` and `DELFWT/PHDELWT` from single-dataset refinement
|
|
16
|
+
- `WDF`, `DF` and `DFc` with `PHIC_dark`, `mDFop-DFc/PHIC_diff`, and the extrapolated maps (with `PHIC_light`) from difference refinement
|
|
17
|
+
- other `F/PHI` column pairs, or CCP4 maps
|
|
18
|
+
|
|
19
|
+
Maps are drawn as Coot-style chicken wire within a 10 Å radius of the centre. The scroll wheel changes the level of the map marked ●.
|
|
20
|
+
- **Real-space refinement.** Choose a zone, the residue ±1 (R), a sphere (Shift-R), or a new alternative conformer. Each refinement builds TorchRef restraints (bonds, angles, torsions, planes, chirals; ligand CIFs included) for the moving residues plus a fixed shell around them. These are minimised against the refinement map (the one marked **R**) with live updates in the view. Drag an atom to pull it; Enter accepts, Esc rejects. Fixed atoms never move.
|
|
21
|
+
- **Difference target.** Set the refinement target to *difference* (or *map + difference*). Moving atoms are then fitted so that the model's light-minus-dark density matches a difference map (WDF by default), with a fitted scale. The dark reference is D.pdb for TorchRef difference refinement, otherwise the residue's other alternative conformers, otherwise the starting coordinates. The banner shows the live difference CC.
|
|
22
|
+
- **Repulsion.** Non-bonded repulsion is off by default, so you can pull atoms on top of each other. Tick *atoms repel* to switch it on.
|
|
23
|
+
- **Building.** Alternative conformers, rotamers ranked by density, mutate, pepflip, adding residues at either terminus, renumbering, deleting, waters (W, or *Find water sites*), ions, ligands from a CIF that contains coordinates, occupancy/B, undo/redo, save.
|
|
24
|
+
- **Peaks tab.** Lists the strongest ± difference peaks with their nearest atom, after symmetry mapping. Click a peak to go there.
|
|
25
|
+
- **Claude tab.** A chat running headless Claude Code with every viewer command as a tool. Each message carries your current view (visible maps and levels, centre, selected atoms), so "this map" means what you're looking at. Claude can take screenshots of the view. The *shell* box lets it run commands such as TorchRef refinements; it's off by default.
|
|
26
|
+
|
|
27
|
+
Press `?` in the viewer for keys and mouse controls.
|
|
28
|
+
|
|
29
|
+
## Using it from any Claude Code session (MCP)
|
|
30
|
+
|
|
31
|
+
The viewer writes its address to `~/.moorlight/server.json`. To attach the tools to any Claude Code session:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
claude mcp add moorlight -- python -m moorlight.mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Claude can then call `state`, `screenshot`, `goto`, `set_map`, `peaks`, `map_correlation`, `refine`, `add_altconf` and the other commands directly.
|
|
38
|
+
|
|
39
|
+
## Notes / limits (v0.1)
|
|
40
|
+
|
|
41
|
+
- Real-space refinement uses trilinear interpolation of the map and a simple non-bonded repulsion. Ramachandran restraints are not used yet.
|
|
42
|
+
- Hydrogens are left out of refinement and follow their parent atom afterwards.
|
|
43
|
+
- Ligands need coordinates in the CIF (from acedrg, grade or eLBOW). There's no RDKit conformer generation yet.
|
|
44
|
+
- Rotamers come from the Penultimate library (modal χ values), ranked by mean density at the side-chain atoms.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""moorlight [files or TorchRef result dirs...] — start the viewer server and open the browser.
|
|
2
|
+
|
|
3
|
+
python -m moorlight refined.pdb refined.mtz
|
|
4
|
+
python -m moorlight path/to/torchref/run/
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import webbrowser
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault("TORCHREF_DEVICE", "cpu")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _free_port(preferred, wait=5.0):
|
|
18
|
+
"""The preferred port, waiting briefly for a previous server to release it; else the next free one."""
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
def free(p):
|
|
22
|
+
with socket.socket() as s:
|
|
23
|
+
try:
|
|
24
|
+
s.bind(("127.0.0.1", p))
|
|
25
|
+
return True
|
|
26
|
+
except OSError:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
t0 = time.time()
|
|
30
|
+
while time.time() - t0 < wait:
|
|
31
|
+
if free(preferred):
|
|
32
|
+
return preferred
|
|
33
|
+
time.sleep(0.25)
|
|
34
|
+
for p in range(preferred + 1, preferred + 50):
|
|
35
|
+
if free(p):
|
|
36
|
+
print(f"port {preferred} is busy, using {p}")
|
|
37
|
+
return p
|
|
38
|
+
raise RuntimeError("no free port")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
ap = argparse.ArgumentParser(prog="moorlight", description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
|
|
43
|
+
ap.add_argument("files", nargs="*", help="PDB/mmCIF models, MTZ files, CCP4 maps, restraint CIFs or TorchRef dirs")
|
|
44
|
+
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8765)))
|
|
45
|
+
ap.add_argument("--no-browser", action="store_true")
|
|
46
|
+
ap.add_argument("--workdir", default=None, help="directory for chat / relative paths (default: first file's dir)")
|
|
47
|
+
ap.add_argument("--sample-rate", type=float, default=3.0, help="map grid sampling (Coot-like 3 ≈ d/6 spacing)")
|
|
48
|
+
ap.add_argument("--allow-bash", action="store_true",
|
|
49
|
+
help="let the built-in Claude chat run shell commands (e.g. TorchRef) without asking")
|
|
50
|
+
ap.add_argument("--model", default=None, help="Claude model for the built-in chat")
|
|
51
|
+
a = ap.parse_args()
|
|
52
|
+
|
|
53
|
+
from aiohttp import web
|
|
54
|
+
from .server import App, write_state_file
|
|
55
|
+
from .session import Session
|
|
56
|
+
|
|
57
|
+
files = [os.path.abspath(f) for f in a.files]
|
|
58
|
+
workdir = a.workdir or (files[0] if files and os.path.isdir(files[0]) else
|
|
59
|
+
os.path.dirname(files[0]) if files else os.getcwd())
|
|
60
|
+
port = _free_port(a.port)
|
|
61
|
+
s = Session(workdir=os.path.abspath(workdir), sample_rate=a.sample_rate)
|
|
62
|
+
app = App(s, chat_opts={"allow_bash": a.allow_bash, "model": a.model, "port": port})
|
|
63
|
+
|
|
64
|
+
for f in files:
|
|
65
|
+
low = f.lower()
|
|
66
|
+
try:
|
|
67
|
+
if os.path.isdir(f):
|
|
68
|
+
s.load_torchref(f)
|
|
69
|
+
elif low.endswith((".pdb", ".ent")) or (low.endswith((".cif", ".mmcif")) and _is_model(f)):
|
|
70
|
+
s.load_model(f)
|
|
71
|
+
elif low.endswith(".cif"):
|
|
72
|
+
s.add_dictionary(f)
|
|
73
|
+
elif low.endswith(".mtz"):
|
|
74
|
+
s.load_mtz(f)
|
|
75
|
+
elif low.endswith((".map", ".ccp4", ".mrc")):
|
|
76
|
+
s.load_map(f)
|
|
77
|
+
else:
|
|
78
|
+
print(f"skipping {f}: unknown type")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
print(f"could not load {f}: {e}")
|
|
81
|
+
|
|
82
|
+
from .rsr import warm_up
|
|
83
|
+
threading.Thread(target=lambda: _quiet(warm_up), daemon=True).start()
|
|
84
|
+
|
|
85
|
+
url = f"http://127.0.0.1:{port}/"
|
|
86
|
+
write_state_file(url, s.workdir)
|
|
87
|
+
print(f"Moorlight: {url}")
|
|
88
|
+
if not a.no_browser:
|
|
89
|
+
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
90
|
+
web.run_app(app.make_app(), host="127.0.0.1", port=port, print=None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _quiet(fn):
|
|
94
|
+
try:
|
|
95
|
+
fn()
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"TorchRef warm-up failed: {e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_model(path):
|
|
101
|
+
import gemmi
|
|
102
|
+
try:
|
|
103
|
+
doc = gemmi.cif.read(path)
|
|
104
|
+
return any(b.find_value("_atom_site.id") is not None or b.find_loop("_atom_site.id") for b in doc)
|
|
105
|
+
except Exception:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Built-in chat: runs headless Claude Code (`claude -p`) with the moorlight MCP tools attached.
|
|
2
|
+
|
|
3
|
+
Every message is sent with a compact description of the current view (visible maps,
|
|
4
|
+
contour levels, centre, selection), so "this map" or "these atoms" resolve to what is
|
|
5
|
+
on screen. The Claude Code session is resumed between messages, so it remembers the
|
|
6
|
+
conversation; it can also be resumed from a terminal with `claude --resume <id>`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
SYSTEM = """You are embedded in "Moorlight", a lightweight Coot-like model-building viewer used for
|
|
18
|
+
crystallographic (mostly time-resolved, TorchRef difference-refinement) work. The user sees a 3D view of
|
|
19
|
+
models and maps. Use the mcp__moorlight__* tools to inspect and change what they see:
|
|
20
|
+
- state: what is loaded/visible (maps with contour levels in sigma, centre, selected atoms, refinement status)
|
|
21
|
+
- screenshot: returns an image of the current view
|
|
22
|
+
- goto / set_map / peaks / map_correlation / density_at_atoms for analysis
|
|
23
|
+
- set_refinement_options: target "map" (2mFo-DFc-like map), "difference" (fit light-minus-dark model density
|
|
24
|
+
to a difference map such as WDF, dark reference = dark model / other altlocs / start coordinates) or "both";
|
|
25
|
+
non-bonded repulsion is off by default
|
|
26
|
+
- refine, add_altconf, rotamers/set_rotamer, mutate, add_water, add_ion, add_ligand, add_residue, renumber,
|
|
27
|
+
delete, fix_atoms, undo for building. Model edits are undoable.
|
|
28
|
+
Each user message starts with a [viewer] block describing the current view; "this map", "here" or "these
|
|
29
|
+
atoms" refer to it. Map numbers like [3] are map ids. Prefer showing the user things (goto, set_map) over
|
|
30
|
+
long explanations. When you run TorchRef refinements, write outputs to a new directory and afterwards load
|
|
31
|
+
them with load_torchref so the user can inspect them. Keep answers short; the chat panel is narrow."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _short_state(s):
|
|
35
|
+
lines = []
|
|
36
|
+
for m in s.molecules.values():
|
|
37
|
+
lines.append(f"molecule {m.id}: {m.name} ({m.path}){' [active]' if m.id == s.active_mol else ''}"
|
|
38
|
+
f"{'' if m.visible else ' [hidden]'}")
|
|
39
|
+
for x in s.maps.values():
|
|
40
|
+
if x.visible:
|
|
41
|
+
lines.append(f"map [{x.id}] {x.name} from {x.source}: shown at {x.level_sigma:.2f} sigma"
|
|
42
|
+
f"{' (+/-)' if x.kind == 'diff' else ''}{' [refinement map]' if x.id == s.refinement_map else ''}")
|
|
43
|
+
hidden = [f"[{x.id}] {x.name}" for x in s.maps.values() if not x.visible]
|
|
44
|
+
if hidden:
|
|
45
|
+
lines.append("hidden maps: " + ", ".join(hidden))
|
|
46
|
+
st = s.state()
|
|
47
|
+
v = st["view"]
|
|
48
|
+
c = v.get("center") or [0, 0, 0]
|
|
49
|
+
lines.append(f"view centre: ({c[0]:.1f}, {c[1]:.1f}, {c[2]:.1f})" +
|
|
50
|
+
(f", nearest atom {v['nearest_atom']}" if v.get("nearest_atom") else ""))
|
|
51
|
+
if v.get("selection_atoms"):
|
|
52
|
+
lines.append("selected atoms: " + ", ".join(v["selection_atoms"][:20]))
|
|
53
|
+
if st.get("refinement"):
|
|
54
|
+
lines.append(f"real-space refinement running on {st['refinement']['moving_atoms']} atoms")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ClaudeChat:
|
|
59
|
+
def __init__(self, session, emit, claude_bin=None, allow_bash=False, model=None, port=None):
|
|
60
|
+
self.s = session
|
|
61
|
+
self.emit = emit
|
|
62
|
+
self.claude = claude_bin or shutil.which("claude") or os.path.expanduser("~/.local/bin/claude")
|
|
63
|
+
self.allow_bash = allow_bash
|
|
64
|
+
self.model = model
|
|
65
|
+
self.port = port
|
|
66
|
+
self.session_id = None
|
|
67
|
+
self.proc = None
|
|
68
|
+
self.history: list[dict] = []
|
|
69
|
+
|
|
70
|
+
def settings(self):
|
|
71
|
+
return {"allow_bash": self.allow_bash, "model": self.model, "session_id": self.session_id,
|
|
72
|
+
"claude": self.claude}
|
|
73
|
+
|
|
74
|
+
def update_settings(self, d):
|
|
75
|
+
if "allow_bash" in d:
|
|
76
|
+
self.allow_bash = bool(d["allow_bash"])
|
|
77
|
+
if "model" in d:
|
|
78
|
+
self.model = d["model"] or None
|
|
79
|
+
|
|
80
|
+
def reset(self):
|
|
81
|
+
self.session_id = None
|
|
82
|
+
self.history.clear()
|
|
83
|
+
|
|
84
|
+
def _mcp_config(self):
|
|
85
|
+
d = os.path.join(self.s.workdir, ".moorlight")
|
|
86
|
+
os.makedirs(d, exist_ok=True)
|
|
87
|
+
p = os.path.join(d, "mcp.json")
|
|
88
|
+
cfg = {"mcpServers": {"moorlight": {
|
|
89
|
+
"command": sys.executable, "args": ["-m", "moorlight.mcp", "--url", f"http://127.0.0.1:{self.port}"],
|
|
90
|
+
"env": {"PYTHONPATH": os.path.dirname(os.path.dirname(os.path.abspath(__file__)))}}}}
|
|
91
|
+
with open(p, "w") as fh:
|
|
92
|
+
json.dump(cfg, fh, indent=1)
|
|
93
|
+
return p
|
|
94
|
+
|
|
95
|
+
async def _out(self, msg):
|
|
96
|
+
self.history.append(msg)
|
|
97
|
+
await self.emit(msg)
|
|
98
|
+
|
|
99
|
+
async def stop(self):
|
|
100
|
+
if self.proc and self.proc.returncode is None:
|
|
101
|
+
self.proc.terminate()
|
|
102
|
+
|
|
103
|
+
async def send(self, text):
|
|
104
|
+
if self.proc and self.proc.returncode is None:
|
|
105
|
+
await self._out({"type": "chat", "role": "system", "text": "Claude is still busy – wait or press stop."})
|
|
106
|
+
return
|
|
107
|
+
await self._out({"type": "chat", "role": "user", "text": text})
|
|
108
|
+
prompt = f"[viewer]\n{_short_state(self.s)}\n[/viewer]\n\n{text}"
|
|
109
|
+
tools = ["mcp__moorlight", "Read", "Glob", "Grep", "TodoWrite"]
|
|
110
|
+
if self.allow_bash:
|
|
111
|
+
tools += ["Bash", "Write", "Edit"]
|
|
112
|
+
args = [self.claude, "-p", prompt, "--output-format", "stream-json", "--verbose",
|
|
113
|
+
"--include-partial-messages", "--mcp-config", self._mcp_config(),
|
|
114
|
+
"--append-system-prompt", SYSTEM, "--allowedTools", *tools]
|
|
115
|
+
if self.session_id:
|
|
116
|
+
args += ["--resume", self.session_id]
|
|
117
|
+
if self.model:
|
|
118
|
+
args += ["--model", self.model]
|
|
119
|
+
t0 = time.time()
|
|
120
|
+
try:
|
|
121
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
122
|
+
*args, cwd=self.s.workdir, stdin=asyncio.subprocess.DEVNULL,
|
|
123
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=16 * 1024 * 1024)
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
await self._out({"type": "chat", "role": "system", "text": f"claude CLI not found at {self.claude}"})
|
|
126
|
+
return
|
|
127
|
+
await self.emit({"type": "chat_busy", "busy": True})
|
|
128
|
+
streamed = False
|
|
129
|
+
try:
|
|
130
|
+
async for raw in self.proc.stdout:
|
|
131
|
+
try:
|
|
132
|
+
ev = json.loads(raw)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
continue
|
|
135
|
+
et = ev.get("type")
|
|
136
|
+
if et == "system" and ev.get("subtype") == "init":
|
|
137
|
+
self.session_id = ev.get("session_id", self.session_id)
|
|
138
|
+
elif et == "stream_event":
|
|
139
|
+
e = ev.get("event", {})
|
|
140
|
+
if e.get("type") == "content_block_delta" and e.get("delta", {}).get("type") == "text_delta":
|
|
141
|
+
streamed = True
|
|
142
|
+
await self.emit({"type": "chat_delta", "text": e["delta"]["text"]})
|
|
143
|
+
elif e.get("type") == "message_start":
|
|
144
|
+
await self.emit({"type": "chat_delta_start"})
|
|
145
|
+
elif et == "assistant":
|
|
146
|
+
for block in ev.get("message", {}).get("content", []):
|
|
147
|
+
if block.get("type") == "text" and block.get("text", "").strip():
|
|
148
|
+
await self._out({"type": "chat", "role": "assistant", "text": block["text"],
|
|
149
|
+
"streamed": streamed})
|
|
150
|
+
elif block.get("type") == "tool_use":
|
|
151
|
+
name = block.get("name", "").replace("mcp__moorlight__", "")
|
|
152
|
+
if name == "ToolSearch":
|
|
153
|
+
continue
|
|
154
|
+
await self._out({"type": "chat", "role": "tool", "text": name,
|
|
155
|
+
"input": json.dumps(block.get("input", {}))[:300]})
|
|
156
|
+
streamed = False
|
|
157
|
+
elif et == "user":
|
|
158
|
+
for block in ev.get("message", {}).get("content", []) or []:
|
|
159
|
+
if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("is_error"):
|
|
160
|
+
c = block.get("content")
|
|
161
|
+
c = c if isinstance(c, str) else json.dumps(c)[:300]
|
|
162
|
+
await self._out({"type": "chat", "role": "tool_error", "text": c[:300]})
|
|
163
|
+
elif et == "result":
|
|
164
|
+
self.session_id = ev.get("session_id", self.session_id)
|
|
165
|
+
if ev.get("is_error"):
|
|
166
|
+
await self._out({"type": "chat", "role": "system", "text": str(ev.get("result"))[:500]})
|
|
167
|
+
cost = ev.get("total_cost_usd")
|
|
168
|
+
await self.emit({"type": "chat_done", "seconds": round(time.time() - t0, 1),
|
|
169
|
+
"cost": cost, "session_id": self.session_id})
|
|
170
|
+
await self.proc.wait()
|
|
171
|
+
if self.proc.returncode not in (0, None, -15):
|
|
172
|
+
err = (await self.proc.stderr.read()).decode()[-800:]
|
|
173
|
+
await self._out({"type": "chat", "role": "system", "text": f"claude exited {self.proc.returncode}: {err}"})
|
|
174
|
+
finally:
|
|
175
|
+
await self.emit({"type": "chat_busy", "busy": False})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Restraint dictionaries embedded in refinement output (phenix mmCIF data_comp_XXX blocks).
|
|
2
|
+
|
|
3
|
+
They are rewritten in the minimal CCP4 layout TorchRef parses reliably: primed atom names
|
|
4
|
+
double-quoted, only atom/bond/angle/tor/plane/chir loops kept (descriptor loops break the reader).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import gemmi
|
|
11
|
+
|
|
12
|
+
KEEP = ["_chem_comp_atom.", "_chem_comp_bond.", "_chem_comp_angle.", "_chem_comp_tor.",
|
|
13
|
+
"_chem_comp_plane_atom.", "_chem_comp_chir."]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _q(v):
|
|
17
|
+
v = gemmi.cif.as_string(v)
|
|
18
|
+
return f'"{v}"' if ("'" in v or " " in v or v == "") else v
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def comp_ids(path):
|
|
22
|
+
try:
|
|
23
|
+
doc = gemmi.cif.read(path)
|
|
24
|
+
except Exception:
|
|
25
|
+
return []
|
|
26
|
+
return [b.name[5:] for b in doc if b.name.startswith("comp_") and b.name != "comp_list"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_all(path, outdir):
|
|
30
|
+
"""Write every data_comp_XXX block of a CIF as its own TorchRef-safe dictionary. Returns paths."""
|
|
31
|
+
doc = gemmi.cif.read(path)
|
|
32
|
+
groups = {}
|
|
33
|
+
for b in doc:
|
|
34
|
+
for row in b.find("_chem_comp.", ["id", "group"]):
|
|
35
|
+
groups[gemmi.cif.as_string(row[0])] = gemmi.cif.as_string(row[1])
|
|
36
|
+
os.makedirs(outdir, exist_ok=True)
|
|
37
|
+
out = []
|
|
38
|
+
for b in doc:
|
|
39
|
+
if not b.name.startswith("comp_") or b.name == "comp_list":
|
|
40
|
+
continue
|
|
41
|
+
comp = b.name[5:]
|
|
42
|
+
atoms = b.find("_chem_comp_atom.", ["atom_id", "type_symbol"])
|
|
43
|
+
if not len(atoms) or not b.find_mmcif_category("_chem_comp_bond."):
|
|
44
|
+
continue # single atoms (ions) need no dictionary, and TorchRef rejects bond-less files
|
|
45
|
+
nh = sum(1 for r in atoms if gemmi.cif.as_string(r[1]).upper() != "H")
|
|
46
|
+
group = groups.get(comp, "non-polymer") or "non-polymer"
|
|
47
|
+
lines = ["data_comp_list", "loop_", "_chem_comp.id", "_chem_comp.three_letter_code", "_chem_comp.name",
|
|
48
|
+
"_chem_comp.group", "_chem_comp.number_atoms_all", "_chem_comp.number_atoms_nh",
|
|
49
|
+
"_chem_comp.desc_level", f"{comp} {comp} {comp} {_q(group)} {len(atoms)} {nh} .", "",
|
|
50
|
+
f"data_comp_{comp}"]
|
|
51
|
+
for pre in KEEP:
|
|
52
|
+
tab = b.find_mmcif_category(pre)
|
|
53
|
+
if not tab:
|
|
54
|
+
continue
|
|
55
|
+
lines.append("loop_")
|
|
56
|
+
lines += [t for t in tab.tags]
|
|
57
|
+
for r in tab:
|
|
58
|
+
lines.append(" ".join(_q(r[i]) for i in range(len(r))))
|
|
59
|
+
p = os.path.join(outdir, f"{comp}.cif")
|
|
60
|
+
with open(p, "w") as fh:
|
|
61
|
+
fh.write("\n".join(lines) + "\n")
|
|
62
|
+
out.append(p)
|
|
63
|
+
return out
|