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.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
quantui/user_settings.py
ADDED
|
@@ -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
|