quantui 0.5.1__py3-none-any.whl

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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. quantui-0.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,433 @@
1
+ """Bundled molecule library — indexed package-data store + lazy loader.
2
+
3
+ The library lives in ``quantui/data/`` as two committed artifacts:
4
+
5
+ - ``manifests/presets.json`` — the human-readable, reviewable source of record.
6
+ - ``library/library.sqlite`` — an indexed, lazily-queried store generated from
7
+ the manifest(s). Coordinates are packed as ``int16`` @ 0.001 Å (7–8 bytes per
8
+ atom) so the store stays compact enough to hold the ~10 MB hybrid library
9
+ without bloating import time.
10
+
11
+ Design goals:
12
+
13
+ - **Lazy + indexed** — search/get touch only the rows they need, so the store
14
+ scales to tens of thousands of bulk entries without loading them all.
15
+ - **Back-compat** — :func:`get_preset_dict` returns the exact legacy
16
+ ``MOLECULE_LIBRARY`` shape, so ``config.MOLECULE_LIBRARY`` and every existing
17
+ consumer keep working unchanged.
18
+ - **Robust** — if the SQLite artifact is missing or unreadable, every entry
19
+ point transparently falls back to the JSON manifest, so ``import quantui``
20
+ never fails on a fresh/odd checkout.
21
+
22
+ Adding curated or bulk content only adds manifests + rebuilds the store;
23
+ nothing in this module's public API changes.
24
+ """
25
+
26
+ import json
27
+ import logging
28
+ import sqlite3
29
+ import struct
30
+ from functools import lru_cache
31
+ from importlib.resources import files
32
+ from pathlib import Path
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # ── Coordinate codec ─────────────────────────────────────────────────────────
38
+ # Per-atom record: 2 bytes element symbol (ascii, 2nd byte 0 if 1-char) +
39
+ # 3× int16 little-endian coordinate in milli-ångström. 8 bytes/atom.
40
+ _COORD_SCALE = 1000 # 0.001 Å resolution; ±32.767 Å range (int16)
41
+ _ATOM_RECORD = "<BBhhh"
42
+ _ATOM_RECORD_SIZE = struct.calcsize(_ATOM_RECORD) # 8
43
+
44
+ # Categories that are NOT part of the browsable preset dict (reached via search
45
+ # only). Bulk QM9 entries carry "bulk-qm9".
46
+ _BULK_CATEGORIES = frozenset({"bulk-qm9"})
47
+
48
+
49
+ def encode_coords(atoms: List[str], coords: List[List[float]]) -> bytes:
50
+ """Pack ``(atoms, coords)`` into the compact per-atom binary record."""
51
+ buf = bytearray()
52
+ for sym, (x, y, z) in zip(atoms, coords):
53
+ s = sym.encode("ascii")
54
+ if not 1 <= len(s) <= 2:
55
+ raise ValueError(f"Unsupported element symbol: {sym!r}")
56
+ c0 = s[0]
57
+ c1 = s[1] if len(s) > 1 else 0
58
+ buf += struct.pack(
59
+ _ATOM_RECORD,
60
+ c0,
61
+ c1,
62
+ round(float(x) * _COORD_SCALE),
63
+ round(float(y) * _COORD_SCALE),
64
+ round(float(z) * _COORD_SCALE),
65
+ )
66
+ return bytes(buf)
67
+
68
+
69
+ def decode_coords(blob: bytes) -> Tuple[List[str], List[List[float]]]:
70
+ """Unpack a coordinate blob back into ``(atoms, coords)``."""
71
+ atoms: List[str] = []
72
+ coords: List[List[float]] = []
73
+ for offset in range(0, len(blob), _ATOM_RECORD_SIZE):
74
+ c0, c1, xi, yi, zi = struct.unpack(
75
+ _ATOM_RECORD, blob[offset : offset + _ATOM_RECORD_SIZE]
76
+ )
77
+ sym = chr(c0) + (chr(c1) if c1 else "")
78
+ atoms.append(sym)
79
+ coords.append([xi / _COORD_SCALE, yi / _COORD_SCALE, zi / _COORD_SCALE])
80
+ return atoms, coords
81
+
82
+
83
+ # ── Paths ────────────────────────────────────────────────────────────────────
84
+ def _data_dir() -> Path:
85
+ return Path(str(files("quantui"))) / "data"
86
+
87
+
88
+ def db_path() -> Path:
89
+ return _data_dir() / "library" / "library.sqlite"
90
+
91
+
92
+ def manifest_paths() -> List[Path]:
93
+ """All manifest JSON files that seed the store.
94
+
95
+ ``presets.json`` is ordered first so the original teaching molecules lead
96
+ the (interim) flat dropdown; remaining manifests follow alphabetically.
97
+ """
98
+ manifest_dir = _data_dir() / "manifests"
99
+ if not manifest_dir.is_dir():
100
+ return []
101
+ return sorted(
102
+ manifest_dir.glob("*.json"),
103
+ key=lambda p: (p.name != "presets.json", p.name),
104
+ )
105
+
106
+
107
+ # ── Manifest loading (always-available fallback source) ──────────────────────
108
+ @lru_cache(maxsize=1)
109
+ def _manifest_entries() -> Tuple[Dict[str, Any], ...]:
110
+ """Load every manifest entry. Cached. The JSON is the source of record."""
111
+ entries: List[Dict[str, Any]] = []
112
+ for path in manifest_paths():
113
+ try:
114
+ entries.extend(json.loads(path.read_text(encoding="utf-8")))
115
+ except Exception as exc: # pragma: no cover - corrupt manifest
116
+ logger.error(f"Failed to read library manifest {path}: {exc}")
117
+ return tuple(entries)
118
+
119
+
120
+ def _normalize_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
121
+ """Fill derived fields (id/formula/n_atoms/...) for a manifest entry."""
122
+ atoms = raw["atoms"]
123
+ formula = raw.get("formula") or raw.get("id") or raw["name"]
124
+ return {
125
+ "id": raw.get("id", formula),
126
+ "name": raw.get("name", formula),
127
+ "formula": formula,
128
+ "category": raw.get("category", "preset"),
129
+ "n_atoms": len(atoms),
130
+ "n_heavy": sum(1 for a in atoms if a != "H"),
131
+ "charge": int(raw.get("charge", 0)),
132
+ "multiplicity": int(raw.get("multiplicity", 1)),
133
+ "source": raw.get("source", "preset"),
134
+ "smiles": raw.get("smiles"),
135
+ "inchikey": raw.get("inchikey"),
136
+ "synonyms": raw.get("synonyms", ""),
137
+ "description": raw.get("description", ""),
138
+ "atoms": atoms,
139
+ "coordinates": raw["coordinates"],
140
+ }
141
+
142
+
143
+ # ── Store build (run by scripts/build_library.py or on-demand) ───────────────
144
+ _SCHEMA = """
145
+ CREATE TABLE molecule (
146
+ id TEXT PRIMARY KEY,
147
+ name TEXT,
148
+ formula TEXT NOT NULL,
149
+ category TEXT NOT NULL,
150
+ n_heavy INTEGER NOT NULL,
151
+ n_atoms INTEGER NOT NULL,
152
+ charge INTEGER NOT NULL DEFAULT 0,
153
+ multiplicity INTEGER NOT NULL DEFAULT 1,
154
+ source TEXT NOT NULL,
155
+ smiles TEXT,
156
+ inchikey TEXT,
157
+ synonyms TEXT,
158
+ description TEXT,
159
+ coords BLOB NOT NULL
160
+ );
161
+ CREATE INDEX idx_name ON molecule(name);
162
+ CREATE INDEX idx_formula ON molecule(formula);
163
+ CREATE INDEX idx_category ON molecule(category);
164
+ CREATE INDEX idx_inchikey ON molecule(inchikey);
165
+ """
166
+
167
+
168
+ def build_store(entries: List[Dict[str, Any]], target: Path) -> Path:
169
+ """(Re)build the SQLite store from a list of (raw) manifest entries.
170
+
171
+ Deterministic: entries are inserted in the order given, so a rebuild from
172
+ the same manifest yields a stable file. Overwrites ``target``.
173
+ """
174
+ target.parent.mkdir(parents=True, exist_ok=True)
175
+ if target.exists():
176
+ target.unlink()
177
+ con = sqlite3.connect(target)
178
+ try:
179
+ con.executescript(_SCHEMA)
180
+ rows = []
181
+ for raw in entries:
182
+ e = _normalize_entry(raw)
183
+ rows.append(
184
+ (
185
+ e["id"],
186
+ e["name"],
187
+ e["formula"],
188
+ e["category"],
189
+ e["n_heavy"],
190
+ e["n_atoms"],
191
+ e["charge"],
192
+ e["multiplicity"],
193
+ e["source"],
194
+ e["smiles"],
195
+ e["inchikey"],
196
+ e["synonyms"],
197
+ e["description"],
198
+ encode_coords(e["atoms"], e["coordinates"]),
199
+ )
200
+ )
201
+ con.executemany(
202
+ "INSERT INTO molecule (id, name, formula, category, n_heavy, n_atoms, "
203
+ "charge, multiplicity, source, smiles, inchikey, synonyms, description, "
204
+ "coords) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
205
+ rows,
206
+ )
207
+ con.commit()
208
+ finally:
209
+ con.close()
210
+ logger.info(f"Built molecule library store: {target} ({len(entries)} entries)")
211
+ return target
212
+
213
+
214
+ def build_from_manifests(target: Optional[Path] = None) -> Path:
215
+ """Build the store from all committed manifests. Returns the store path."""
216
+ target = target or db_path()
217
+ return build_store([dict(e) for e in _manifest_entries()], target)
218
+
219
+
220
+ def _ensure_store() -> Optional[Path]:
221
+ """Best-effort: ensure the SQLite store exists. Returns its path or None.
222
+
223
+ Never raises — if the package dir is read-only (installed wheel) and the
224
+ store is somehow absent, callers fall back to the JSON manifest.
225
+ """
226
+ path = db_path()
227
+ if path.exists():
228
+ return path
229
+ try:
230
+ if _manifest_entries():
231
+ return build_from_manifests(path)
232
+ except Exception as exc: # pragma: no cover - read-only install w/o store
233
+ logger.warning(f"Could not build library store on demand: {exc}")
234
+ return None
235
+
236
+
237
+ # ── Query API ────────────────────────────────────────────────────────────────
238
+ def _connect_ro() -> Optional[sqlite3.Connection]:
239
+ """Open a fresh read-only connection (thread-safe per-call), or None."""
240
+ path = _ensure_store()
241
+ if path is None or not path.exists():
242
+ return None
243
+ con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
244
+ con.row_factory = sqlite3.Row
245
+ return con
246
+
247
+
248
+ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
249
+ atoms, coords = decode_coords(row["coords"])
250
+ return {
251
+ "id": row["id"],
252
+ "name": row["name"],
253
+ "formula": row["formula"],
254
+ "category": row["category"],
255
+ "n_heavy": row["n_heavy"],
256
+ "n_atoms": row["n_atoms"],
257
+ "charge": row["charge"],
258
+ "multiplicity": row["multiplicity"],
259
+ "source": row["source"],
260
+ "smiles": row["smiles"],
261
+ "inchikey": row["inchikey"],
262
+ "synonyms": row["synonyms"],
263
+ "description": row["description"],
264
+ "atoms": atoms,
265
+ "coordinates": coords,
266
+ }
267
+
268
+
269
+ @lru_cache(maxsize=1)
270
+ def get_preset_dict() -> Dict[str, Dict[str, Any]]:
271
+ """Return the curated/preset entries in the legacy ``MOLECULE_LIBRARY`` shape.
272
+
273
+ Keyed by entry id (the formula key, e.g. ``"H2O"``); each value carries the
274
+ legacy ``atoms`` / ``coordinates`` / ``charge`` / ``multiplicity`` /
275
+ ``description`` fields. Bulk categories are excluded (they are reached via
276
+ :func:`search`, never the browse dropdown). Preserves manifest order.
277
+
278
+ Prefers the SQLite store; falls back to the JSON manifest if the store is
279
+ unavailable, so this never hard-fails.
280
+ """
281
+ out: Dict[str, Dict[str, Any]] = {}
282
+ con = _connect_ro()
283
+ if con is not None:
284
+ try:
285
+ placeholders = ",".join("?" * len(_BULK_CATEGORIES))
286
+ rows = con.execute(
287
+ f"SELECT * FROM molecule WHERE category NOT IN ({placeholders}) "
288
+ "ORDER BY rowid",
289
+ tuple(_BULK_CATEGORIES),
290
+ ).fetchall()
291
+ for row in rows:
292
+ e = _row_to_entry(row)
293
+ out[e["id"]] = _legacy_view(e)
294
+ return out
295
+ except Exception as exc: # pragma: no cover - corrupt store
296
+ logger.warning(f"Library store unreadable, using manifest: {exc}")
297
+ finally:
298
+ con.close()
299
+ # JSON fallback.
300
+ for raw in _manifest_entries():
301
+ e = _normalize_entry(raw)
302
+ if e["category"] in _BULK_CATEGORIES:
303
+ continue
304
+ out[e["id"]] = _legacy_view(e)
305
+ return out
306
+
307
+
308
+ def _legacy_view(entry: Dict[str, Any]) -> Dict[str, Any]:
309
+ """Project a full entry down to the legacy MOLECULE_LIBRARY value shape."""
310
+ return {
311
+ "atoms": entry["atoms"],
312
+ "coordinates": entry["coordinates"],
313
+ "charge": entry["charge"],
314
+ "multiplicity": entry["multiplicity"],
315
+ "description": entry["description"],
316
+ }
317
+
318
+
319
+ def get(entry_id: str) -> Optional[Dict[str, Any]]:
320
+ """Fetch one full entry (incl. decoded coordinates) by id, or None."""
321
+ con = _connect_ro()
322
+ if con is not None:
323
+ try:
324
+ row = con.execute(
325
+ "SELECT * FROM molecule WHERE id = ?", (entry_id,)
326
+ ).fetchone()
327
+ return _row_to_entry(row) if row else None
328
+ finally:
329
+ con.close()
330
+ for raw in _manifest_entries():
331
+ e = _normalize_entry(raw)
332
+ if e["id"] == entry_id:
333
+ return e
334
+ return None
335
+
336
+
337
+ def search(
338
+ query: str = "", *, category: Optional[str] = None, limit: int = 50
339
+ ) -> List[Dict[str, Any]]:
340
+ """Search the library by name / formula / synonyms (substring, case-insensitive).
341
+
342
+ Returns lightweight rows (no coordinates) for listing; call :func:`get` to
343
+ materialize the chosen entry. Empty ``query`` lists entries (optionally
344
+ filtered by ``category``).
345
+ """
346
+ q = f"%{query.strip().lower()}%"
347
+ con = _connect_ro()
348
+ if con is not None:
349
+ try:
350
+ sql = (
351
+ "SELECT id, name, formula, category, n_heavy, n_atoms, charge, "
352
+ "multiplicity, source FROM molecule WHERE "
353
+ "(lower(name) LIKE ? OR lower(formula) LIKE ? OR lower(synonyms) LIKE ?)"
354
+ )
355
+ params: List[Any] = [q, q, q]
356
+ if category:
357
+ sql += " AND category = ?"
358
+ params.append(category)
359
+ sql += " ORDER BY n_atoms, id LIMIT ?"
360
+ params.append(limit)
361
+ return [dict(r) for r in con.execute(sql, params).fetchall()]
362
+ finally:
363
+ con.close()
364
+ # JSON fallback (linear scan).
365
+ needle = query.strip().lower()
366
+ results = []
367
+ for raw in _manifest_entries():
368
+ e = _normalize_entry(raw)
369
+ hay = f"{e['name']} {e['formula']} {e['synonyms']}".lower()
370
+ if needle in hay and (category is None or e["category"] == category):
371
+ results.append(
372
+ {
373
+ k: e[k]
374
+ for k in (
375
+ "id",
376
+ "name",
377
+ "formula",
378
+ "category",
379
+ "n_heavy",
380
+ "n_atoms",
381
+ "charge",
382
+ "multiplicity",
383
+ "source",
384
+ )
385
+ }
386
+ )
387
+ results.sort(key=lambda r: (r["n_atoms"], r["id"]))
388
+ return results[:limit]
389
+
390
+
391
+ def categories() -> List[str]:
392
+ """Distinct category labels present in the library."""
393
+ con = _connect_ro()
394
+ if con is not None:
395
+ try:
396
+ return [
397
+ r[0]
398
+ for r in con.execute(
399
+ "SELECT DISTINCT category FROM molecule ORDER BY category"
400
+ ).fetchall()
401
+ ]
402
+ finally:
403
+ con.close()
404
+ return sorted({_normalize_entry(e)["category"] for e in _manifest_entries()})
405
+
406
+
407
+ def count() -> int:
408
+ """Total number of entries in the library."""
409
+ con = _connect_ro()
410
+ if con is not None:
411
+ try:
412
+ return int(con.execute("SELECT COUNT(*) FROM molecule").fetchone()[0])
413
+ finally:
414
+ con.close()
415
+ return len(_manifest_entries())
416
+
417
+
418
+ def iter_entries():
419
+ """Yield every entry (full, with decoded coordinates) in store order.
420
+
421
+ Single connection — efficient for whole-library governance/round-trip
422
+ checks. Falls back to the JSON manifest if the store is absent.
423
+ """
424
+ con = _connect_ro()
425
+ if con is None:
426
+ for raw in _manifest_entries():
427
+ yield _normalize_entry(raw)
428
+ return
429
+ try:
430
+ for row in con.execute("SELECT * FROM molecule ORDER BY rowid"):
431
+ yield _row_to_entry(row)
432
+ finally:
433
+ con.close()