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,238 @@
1
+ """
2
+ User preference persistence for QuantUI.
3
+
4
+ Settings are stored at ``~/.quantui/settings.json`` (override with the
5
+ ``QUANTUI_SETTINGS_PATH`` environment variable for testing). The schema is
6
+ section-based for additive growth — new feature areas (theme, defaults,
7
+ etc.) add their own top-level sections without breaking existing readers.
8
+
9
+ Robustness rules
10
+ ----------------
11
+ - **Atomic writes** — write to ``settings.json.tmp`` then rename, so a
12
+ crash mid-write cannot corrupt the file.
13
+ - **Graceful fallback** — missing file, malformed JSON, unknown schema
14
+ version, missing sections, or invalid field values all silently fall
15
+ back to defaults with a single warning log. Startup never crashes on
16
+ bad settings.
17
+ - **Additive-friendly** — new fields use defaults if absent in older
18
+ saved files. Unknown fields are tolerated on read (no crash).
19
+ - **Schema versioning** — ``_schema_version`` is bumped only for breaking
20
+ changes; additive changes keep the same version. Mismatched versions
21
+ fall back to defaults.
22
+
23
+ Typical usage
24
+ -------------
25
+ >>> from quantui.user_settings import UserSettings
26
+ >>> settings = UserSettings.load() # at app startup
27
+ >>> settings.viz.default_backend = "py3dmol"
28
+ >>> settings.save() # on settings change
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import os
36
+ from dataclasses import asdict, dataclass, field
37
+ from pathlib import Path
38
+
39
+ _SCHEMA_VERSION = 1
40
+ _LOG = logging.getLogger(__name__)
41
+
42
+ # Valid values for VizSettings.default_backend. Kept in sync with
43
+ # quantui.viz_backend_router.VizPreference values; not imported here to keep
44
+ # this module zero-dependency for unit testing.
45
+ _VALID_VIZ_BACKENDS = ("auto", "py3dmol", "plotlymol")
46
+
47
+ # Vibrational animation playback rate. Clamped to a sensible range on load
48
+ # so a corrupt value can't produce a 0-ms or absurdly high interval.
49
+ _VIB_FPS_MIN = 1
50
+ _VIB_FPS_MAX = 120
51
+ _VIB_FPS_DEFAULT = 10
52
+
53
+ # Default settings path. The QUANTUI_SETTINGS_PATH env var overrides for tests.
54
+ DEFAULT_SETTINGS_PATH = Path.home() / ".quantui" / "settings.json"
55
+
56
+
57
+ @dataclass
58
+ class VizSettings:
59
+ """Visualization-related user preferences."""
60
+
61
+ default_backend: str = "auto" # one of _VALID_VIZ_BACKENDS
62
+ vib_framerate_fps: int = _VIB_FPS_DEFAULT # py3Dmol vib-animation fps
63
+
64
+
65
+ @dataclass
66
+ class ComputeSettings:
67
+ """Compute-related user preferences."""
68
+
69
+ # Whether GPU offload may engage when a CUDA device is detected. Default on
70
+ # — the historical behavior. Users on consumer cards (weak FP64, see
71
+ # gpu_offload.is_low_fp64_device) may want this off, since offload can be
72
+ # slower than a many-core CPU there. ``QUANTUI_DISABLE_GPU=1`` still wins
73
+ # over this setting.
74
+ gpu_enabled: bool = True
75
+
76
+
77
+ @dataclass
78
+ class UserSettings:
79
+ """Root user settings container — section-based for additive growth."""
80
+
81
+ viz: VizSettings = field(default_factory=VizSettings)
82
+ compute: ComputeSettings = field(default_factory=ComputeSettings)
83
+
84
+ @classmethod
85
+ def load(cls, path: Path | None = None) -> UserSettings:
86
+ """Load settings from disk, falling back to defaults on any failure.
87
+
88
+ Missing file, malformed JSON, unknown schema version, malformed
89
+ sections, and invalid field values all return the default
90
+ ``UserSettings`` with one warning log per failure mode.
91
+ """
92
+ resolved = cls._resolve_path(path)
93
+ if not resolved.exists():
94
+ return cls()
95
+ try:
96
+ data = json.loads(resolved.read_text(encoding="utf-8"))
97
+ except (OSError, json.JSONDecodeError) as exc:
98
+ _LOG.warning(
99
+ "Failed to read settings at %s (%s); using defaults",
100
+ resolved,
101
+ exc,
102
+ )
103
+ return cls()
104
+ return cls._from_dict(data)
105
+
106
+ @classmethod
107
+ def _from_dict(cls, data: object) -> UserSettings:
108
+ """Parse a deserialized JSON object into a UserSettings instance."""
109
+ if not isinstance(data, dict):
110
+ _LOG.warning(
111
+ "Settings root is not a JSON object (got %s); using defaults",
112
+ type(data).__name__,
113
+ )
114
+ return cls()
115
+
116
+ version = data.get("_schema_version")
117
+ if version != _SCHEMA_VERSION:
118
+ _LOG.warning(
119
+ "Settings schema version %r does not match current %r; "
120
+ "using defaults",
121
+ version,
122
+ _SCHEMA_VERSION,
123
+ )
124
+ return cls()
125
+
126
+ viz_section = data.get("viz", {})
127
+ if not isinstance(viz_section, dict):
128
+ _LOG.warning(
129
+ "Settings 'viz' section is not an object (got %s); "
130
+ "using viz defaults",
131
+ type(viz_section).__name__,
132
+ )
133
+ viz_section = {}
134
+
135
+ viz = VizSettings()
136
+ candidate_backend = viz_section.get("default_backend", viz.default_backend)
137
+ if (
138
+ isinstance(candidate_backend, str)
139
+ and candidate_backend in _VALID_VIZ_BACKENDS
140
+ ):
141
+ viz.default_backend = candidate_backend
142
+ else:
143
+ _LOG.warning(
144
+ "Invalid viz.default_backend %r; using %r",
145
+ candidate_backend,
146
+ viz.default_backend,
147
+ )
148
+
149
+ candidate_fps = viz_section.get("vib_framerate_fps", viz.vib_framerate_fps)
150
+ if (
151
+ isinstance(candidate_fps, int)
152
+ and not isinstance(candidate_fps, bool)
153
+ and _VIB_FPS_MIN <= candidate_fps <= _VIB_FPS_MAX
154
+ ):
155
+ viz.vib_framerate_fps = candidate_fps
156
+ else:
157
+ _LOG.warning(
158
+ "Invalid viz.vib_framerate_fps %r; using %r",
159
+ candidate_fps,
160
+ viz.vib_framerate_fps,
161
+ )
162
+
163
+ compute_section = data.get("compute", {})
164
+ if not isinstance(compute_section, dict):
165
+ _LOG.warning(
166
+ "Settings 'compute' section is not an object (got %s); "
167
+ "using compute defaults",
168
+ type(compute_section).__name__,
169
+ )
170
+ compute_section = {}
171
+
172
+ compute = ComputeSettings()
173
+ if "gpu_enabled" in compute_section:
174
+ candidate_gpu = compute_section["gpu_enabled"]
175
+ if isinstance(candidate_gpu, bool):
176
+ compute.gpu_enabled = candidate_gpu
177
+ else:
178
+ _LOG.warning(
179
+ "Invalid compute.gpu_enabled %r; using %r",
180
+ candidate_gpu,
181
+ compute.gpu_enabled,
182
+ )
183
+
184
+ return cls(viz=viz, compute=compute)
185
+
186
+ def to_dict(self) -> dict:
187
+ """Serialize to a dict for JSON storage with the current schema version."""
188
+ return {
189
+ "_schema_version": _SCHEMA_VERSION,
190
+ "viz": asdict(self.viz),
191
+ "compute": asdict(self.compute),
192
+ }
193
+
194
+ def save(self, path: Path | None = None) -> None:
195
+ """Write settings to disk atomically (write to .tmp then rename).
196
+
197
+ Does not raise on filesystem failure — logs a warning and continues.
198
+ Callers should not assume the save succeeded on a hostile filesystem;
199
+ the next load will fall back to defaults if the file is missing.
200
+ """
201
+ resolved = self._resolve_path(path)
202
+ try:
203
+ resolved.parent.mkdir(parents=True, exist_ok=True)
204
+ except OSError as exc:
205
+ _LOG.warning(
206
+ "Failed to create settings parent dir %s (%s); save aborted",
207
+ resolved.parent,
208
+ exc,
209
+ )
210
+ return
211
+
212
+ tmp = resolved.with_suffix(resolved.suffix + ".tmp")
213
+ try:
214
+ tmp.write_text(
215
+ json.dumps(self.to_dict(), indent=2) + "\n",
216
+ encoding="utf-8",
217
+ )
218
+ os.replace(tmp, resolved)
219
+ except OSError as exc:
220
+ _LOG.warning(
221
+ "Failed to save settings to %s (%s)",
222
+ resolved,
223
+ exc,
224
+ )
225
+ try:
226
+ if tmp.exists():
227
+ tmp.unlink()
228
+ except OSError:
229
+ pass
230
+
231
+ @classmethod
232
+ def _resolve_path(cls, path: Path | None) -> Path:
233
+ if path is not None:
234
+ return Path(path)
235
+ env_override = os.environ.get("QUANTUI_SETTINGS_PATH")
236
+ if env_override:
237
+ return Path(env_override)
238
+ return DEFAULT_SETTINGS_PATH
quantui/utils.py ADDED
@@ -0,0 +1,287 @@
1
+ """
2
+ QuantUI Utilities Module
3
+
4
+ Helper functions for validation, session resource detection, and general
5
+ utilities used across the application. SLURM-specific helpers (job ID
6
+ parsing, walltime formatting, job directory management) have been removed.
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import List, Optional, Tuple
15
+
16
+ from . import config
17
+
18
+ # M14 audit fix (2026-07-14): logging.basicConfig() configures the *root*
19
+ # logger process-wide the moment this module is imported (which
20
+ # quantui/__init__ always does) — a library must never do this, since it
21
+ # silently hijacks/duplicates whatever logging setup the host application
22
+ # or notebook already has. quantui/__init__.py already attaches a
23
+ # NullHandler to the package logger; host apps that want console output
24
+ # can call logging.basicConfig() themselves, optionally using
25
+ # config.LOG_LEVEL / config.LOG_FORMAT as defaults.
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def get_username() -> str:
30
+ """
31
+ Detect the current username from environment variables.
32
+
33
+ Tries multiple environment variables to handle different systems:
34
+ - JUPYTERHUB_USER (JupyterHub)
35
+ - USER (Linux/Mac)
36
+ - USERNAME (Windows)
37
+
38
+ Returns:
39
+ str: The detected username
40
+
41
+ Raises:
42
+ RuntimeError: If username cannot be detected
43
+ """
44
+ username = (
45
+ os.getenv("JUPYTERHUB_USER") or os.getenv("USER") or os.getenv("USERNAME")
46
+ )
47
+
48
+ if not username:
49
+ raise RuntimeError(
50
+ "Could not detect username. Please ensure you are running in a "
51
+ "supported environment (JupyterHub, Linux, Mac, or Windows)."
52
+ )
53
+
54
+ username = sanitize_filename(username)
55
+ logger.info(f"Detected username: {username}")
56
+ return username
57
+
58
+
59
+ def sanitize_filename(filename: str) -> str:
60
+ """
61
+ Sanitize a string for safe use in filenames and paths.
62
+
63
+ Args:
64
+ filename: The string to sanitize
65
+
66
+ Returns:
67
+ str: Sanitized string safe for use in filenames
68
+ """
69
+ filename = filename.replace(" ", "_")
70
+ filename = re.sub(r"[^\w\-.]", "", filename)
71
+ return filename
72
+
73
+
74
+ def ensure_directory(path: Path) -> Path:
75
+ """
76
+ Ensure a directory exists, creating it if necessary.
77
+
78
+ Args:
79
+ path: Path to the directory
80
+
81
+ Returns:
82
+ Path: The path object (for chaining)
83
+ """
84
+ path = Path(path)
85
+ path.mkdir(parents=True, exist_ok=True)
86
+ logger.debug(f"Ensured directory exists: {path}")
87
+ return path
88
+
89
+
90
+ def validate_atom_symbol(symbol: str) -> bool:
91
+ """
92
+ Validate if a string is a valid atomic symbol.
93
+
94
+ Args:
95
+ symbol: Atomic symbol to validate (e.g., 'H', 'C', 'O')
96
+
97
+ Returns:
98
+ bool: True if valid, False otherwise
99
+ """
100
+ return symbol.strip() in config.VALID_ATOMS
101
+
102
+
103
+ def validate_coordinates(coords: List[float]) -> bool:
104
+ """
105
+ Validate that coordinates are a list of 3 numbers.
106
+
107
+ Args:
108
+ coords: List of coordinate values
109
+
110
+ Returns:
111
+ bool: True if valid, False otherwise
112
+ """
113
+ if not isinstance(coords, (list, tuple)):
114
+ return False
115
+ if len(coords) != 3:
116
+ return False
117
+ try:
118
+ [float(x) for x in coords]
119
+ return True
120
+ except (ValueError, TypeError):
121
+ return False
122
+
123
+
124
+ def validate_charge(charge: int) -> bool:
125
+ """
126
+ Validate molecular charge.
127
+
128
+ Args:
129
+ charge: Charge value
130
+
131
+ Returns:
132
+ bool: True if valid (reasonable range), False otherwise
133
+ """
134
+ if not isinstance(charge, int):
135
+ return False
136
+ return -10 <= charge <= 10
137
+
138
+
139
+ def validate_multiplicity(multiplicity: int) -> bool:
140
+ """
141
+ Validate spin multiplicity.
142
+
143
+ Args:
144
+ multiplicity: Multiplicity value (2S+1)
145
+
146
+ Returns:
147
+ bool: True if valid, False otherwise
148
+ """
149
+ try:
150
+ multiplicity = int(multiplicity)
151
+ return 1 <= multiplicity <= 10
152
+ except (ValueError, TypeError):
153
+ return False
154
+
155
+
156
+ def student_friendly_error(error: Exception, context: str = "") -> str:
157
+ """
158
+ Convert technical errors into student-friendly messages.
159
+
160
+ Args:
161
+ error: The exception that occurred
162
+ context: Additional context about what was being attempted
163
+
164
+ Returns:
165
+ str: User-friendly error message
166
+ """
167
+ error_str = str(error).lower()
168
+
169
+ if "command not found" in error_str or "no such file" in error_str:
170
+ return (
171
+ f"System Error: Required software not found. "
172
+ f"Please contact your instructor.\n"
173
+ f"Technical details: {context}"
174
+ )
175
+
176
+ if "permission denied" in error_str:
177
+ return (
178
+ f"Permission Error: You don't have access to perform this operation. "
179
+ f"Please contact your instructor.\n"
180
+ f"Technical details: {context}"
181
+ )
182
+
183
+ if "connection" in error_str or "timeout" in error_str:
184
+ return (
185
+ f"Connection Error: Cannot reach the requested service. "
186
+ f"Please check your network connection or try again later.\n"
187
+ f"Technical details: {context}"
188
+ )
189
+
190
+ return (
191
+ f"Error: Something went wrong while {context}. "
192
+ f"Please try again or contact your instructor if the problem persists.\n"
193
+ f"Technical details: {str(error)}"
194
+ )
195
+
196
+
197
+ def format_file_size(size_bytes: int) -> str:
198
+ """
199
+ Format file size in human-readable format.
200
+
201
+ Args:
202
+ size_bytes: File size in bytes
203
+
204
+ Returns:
205
+ str: Formatted size (e.g., "1.5 MB")
206
+ """
207
+ size = float(size_bytes)
208
+ for unit in ["B", "KB", "MB", "GB"]:
209
+ if size < 1024.0:
210
+ return f"{size:.1f} {unit}"
211
+ size /= 1024.0
212
+ return f"{size:.1f} TB"
213
+
214
+
215
+ def get_timestamp() -> str:
216
+ """
217
+ Get current timestamp in ISO format.
218
+
219
+ Returns:
220
+ str: ISO format timestamp
221
+ """
222
+ return datetime.now().isoformat()
223
+
224
+
225
+ def truncate_string(s: str, max_length: int = 100) -> str:
226
+ """
227
+ Truncate a string to maximum length with ellipsis.
228
+
229
+ Args:
230
+ s: String to truncate
231
+ max_length: Maximum length
232
+
233
+ Returns:
234
+ str: Truncated string
235
+ """
236
+ if len(s) <= max_length:
237
+ return s
238
+ return s[: max_length - 3] + "..."
239
+
240
+
241
+ def get_session_resources() -> Tuple[int, Optional[int]]:
242
+ """
243
+ Detect available CPU cores and memory in the current process environment.
244
+
245
+ Returns:
246
+ Tuple of (available_cores, available_memory_gb).
247
+ available_memory_gb is None when psutil is not installed.
248
+ """
249
+ try:
250
+ # sched_getaffinity respects cgroup/container CPU limits (Linux only)
251
+ available_cores = len(os.sched_getaffinity(0)) # type: ignore[attr-defined]
252
+ except (AttributeError, OSError, NotImplementedError):
253
+ # Windows / macOS fallback, or restricted container without sched_getaffinity
254
+ available_cores = os.cpu_count() or 1
255
+ try:
256
+ import psutil
257
+
258
+ mem_gb: Optional[int] = psutil.virtual_memory().available // (1024**3)
259
+ except ImportError:
260
+ mem_gb = None
261
+ return available_cores, mem_gb
262
+
263
+
264
+ def session_can_handle(
265
+ estimated_cores: int,
266
+ estimated_memory_gb: int,
267
+ pyscf_available: bool = True,
268
+ ) -> bool:
269
+ """
270
+ Return True if the current session can likely run a calculation locally.
271
+
272
+ Args:
273
+ estimated_cores: Number of cores the job needs.
274
+ estimated_memory_gb: Memory the job needs in GB.
275
+ pyscf_available: Whether PySCF is importable in this environment.
276
+
277
+ Returns:
278
+ True if local execution looks feasible, False otherwise.
279
+ """
280
+ if not pyscf_available:
281
+ return False
282
+ cores, mem_gb = get_session_resources()
283
+ if estimated_cores > cores:
284
+ return False
285
+ if mem_gb is not None and estimated_memory_gb > mem_gb:
286
+ return False
287
+ return True