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.
@@ -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.
@@ -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,3 @@
1
+ """Moorlight: Coot 0.9-style model building in the browser with TorchRef real-space refinement."""
2
+
3
+ __version__ = "0.1.0"
@@ -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