eagleplot 3.0.0__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.
- eagleplot/Home.py +362 -0
- eagleplot/__init__.py +19 -0
- eagleplot/annotations.py +215 -0
- eagleplot/assets/logo.ico +0 -0
- eagleplot/assets/logo.png +0 -0
- eagleplot/assets/nccu-horiz-logo.png +0 -0
- eagleplot/assets/nccu-wings.png +0 -0
- eagleplot/batch.py +446 -0
- eagleplot/cli.py +719 -0
- eagleplot/data_tools.py +849 -0
- eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
- eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
- eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
- eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
- eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
- eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
- eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
- eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
- eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
- eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
- eagleplot/gallery/box_plot.png +0 -0
- eagleplot/gallery/contour_plot.png +0 -0
- eagleplot/gallery/distribution_plot.png +0 -0
- eagleplot/gallery/heatmap.png +0 -0
- eagleplot/gallery/histogram.png +0 -0
- eagleplot/gallery/interactive_3d_surface.png +0 -0
- eagleplot/gallery/interactive_heatmap.png +0 -0
- eagleplot/gallery/interactive_histogram.png +0 -0
- eagleplot/gallery/interactive_line.png +0 -0
- eagleplot/gallery/interactive_scatter.png +0 -0
- eagleplot/gallery/line_plot.png +0 -0
- eagleplot/gallery/manifest.json +196 -0
- eagleplot/gallery/regression_plot.png +0 -0
- eagleplot/gallery/scatter_plot.png +0 -0
- eagleplot/io.py +1008 -0
- eagleplot/math.py +1210 -0
- eagleplot/molecular/__init__.py +21 -0
- eagleplot/molecular/atom_data.py +287 -0
- eagleplot/molecular/parsers.py +747 -0
- eagleplot/nist.py +241 -0
- eagleplot/pages/10_Molecular_Viz.py +225 -0
- eagleplot/pages/11_Batch_Analysis.py +325 -0
- eagleplot/pages/12_Help.py +199 -0
- eagleplot/pages/13_Settings.py +388 -0
- eagleplot/pages/1_Data_Upload.py +521 -0
- eagleplot/pages/2_Quick_Plot.py +1693 -0
- eagleplot/pages/3_Analysis_Tools.py +2510 -0
- eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
- eagleplot/pages/5_Advanced_Plotting.py +691 -0
- eagleplot/pages/6_Export_Results.py +442 -0
- eagleplot/pages/7_Gallery.py +201 -0
- eagleplot/pages/8_Data_Tools.py +434 -0
- eagleplot/pages/9_Spectroscopy.py +821 -0
- eagleplot/pages/__init__.py +8 -0
- eagleplot/peaks.py +513 -0
- eagleplot/plotting.py +2630 -0
- eagleplot/plugin_loader.py +208 -0
- eagleplot/report.py +448 -0
- eagleplot/signal.py +494 -0
- eagleplot/spectroscopy.py +830 -0
- eagleplot/utils/__init__.py +76 -0
- eagleplot/utils/access.py +77 -0
- eagleplot/utils/data_preview.py +352 -0
- eagleplot/utils/plot_config.py +2721 -0
- eagleplot/utils/session_state.py +535 -0
- eagleplot/utils/theming.py +312 -0
- eagleplot/utils/user_settings.py +294 -0
- eagleplot-3.0.0.dist-info/METADATA +304 -0
- eagleplot-3.0.0.dist-info/RECORD +73 -0
- eagleplot-3.0.0.dist-info/WHEEL +5 -0
- eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
- eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
- eagleplot-3.0.0.dist-info/top_level.txt +1 -0
eagleplot/nist.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""NIST Chemistry WebBook integration.
|
|
2
|
+
|
|
3
|
+
Provides functions to fetch IR spectra and compound information from the
|
|
4
|
+
NIST Chemistry WebBook (https://webbook.nist.gov) using CAS Registry Numbers
|
|
5
|
+
or compound names.
|
|
6
|
+
|
|
7
|
+
No authentication is required. Requires the ``requests`` package.
|
|
8
|
+
|
|
9
|
+
Public API
|
|
10
|
+
----------
|
|
11
|
+
fetch_ir_spectrum(cas_or_id, index=0) -> pd.DataFrame
|
|
12
|
+
Download the IR JCAMP-DX spectrum for a compound and return as DataFrame.
|
|
13
|
+
get_compound_url(cas_or_id) -> str
|
|
14
|
+
Return the NIST WebBook URL for a compound page.
|
|
15
|
+
get_ir_jcamp_url(cas_or_id, index=0) -> str
|
|
16
|
+
Return the direct JCAMP-DX download URL for an IR spectrum.
|
|
17
|
+
search_url(query, by='name') -> str
|
|
18
|
+
Return the NIST WebBook search URL (for 'name' or 'formula' queries).
|
|
19
|
+
list_ir_spectra(cas_or_id) -> list[dict]
|
|
20
|
+
Return metadata for all available IR spectra for a compound.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import tempfile
|
|
27
|
+
from typing import Any, Dict, List
|
|
28
|
+
|
|
29
|
+
import numpy as np # noqa: F401 (kept for downstream users who import from this module)
|
|
30
|
+
|
|
31
|
+
_NIST_BASE = "https://webbook.nist.gov/cgi/cbook.cgi"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _to_nist_id(cas_or_id: str) -> str:
|
|
35
|
+
"""Convert a CAS number to NIST compound ID format (e.g. '64-17-5' → 'C64175').
|
|
36
|
+
|
|
37
|
+
If the value already looks like a NIST ID (starts with a letter other than
|
|
38
|
+
a digit), it is returned unchanged.
|
|
39
|
+
"""
|
|
40
|
+
stripped = cas_or_id.strip()
|
|
41
|
+
# Already a NIST-style ID (e.g. 'C64175', 'InChI=...') — leave it alone
|
|
42
|
+
if not stripped[0].isdigit():
|
|
43
|
+
return stripped
|
|
44
|
+
# CAS number: strip dashes and prepend 'C'
|
|
45
|
+
return "C" + stripped.replace("-", "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_compound_url(cas_or_id: str) -> str:
|
|
49
|
+
"""Return the NIST WebBook compound page URL for a CAS number or NIST ID.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
cas_or_id : str
|
|
54
|
+
CAS Registry Number (e.g. ``'64-17-5'``) or NIST compound ID
|
|
55
|
+
(e.g. ``'C64175'``).
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
str
|
|
60
|
+
Full URL to the compound's NIST WebBook page.
|
|
61
|
+
|
|
62
|
+
Examples
|
|
63
|
+
--------
|
|
64
|
+
>>> get_compound_url('64-17-5')
|
|
65
|
+
'https://webbook.nist.gov/cgi/cbook.cgi?ID=64-17-5&Units=SI'
|
|
66
|
+
"""
|
|
67
|
+
return f"{_NIST_BASE}?ID={cas_or_id}&Units=SI"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_ir_jcamp_url(cas_or_id: str, index: int = 0) -> str:
|
|
71
|
+
"""Return the direct JCAMP-DX download URL for an IR spectrum.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
cas_or_id : str
|
|
76
|
+
CAS Registry Number (e.g. ``'64-17-5'``) or NIST compound ID
|
|
77
|
+
(e.g. ``'C64175'``).
|
|
78
|
+
index : int, default 0
|
|
79
|
+
Spectrum index (0 = first available IR spectrum).
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
str
|
|
84
|
+
Direct URL to the JCAMP-DX file.
|
|
85
|
+
|
|
86
|
+
Notes
|
|
87
|
+
-----
|
|
88
|
+
NIST's JCAMP endpoint uses ``?JCAMP=<compound_id>`` as the primary
|
|
89
|
+
parameter, where the compound ID is the CAS number with dashes removed
|
|
90
|
+
and a ``C`` prefix (e.g. CAS ``64-17-5`` → ``C64175``).
|
|
91
|
+
"""
|
|
92
|
+
nist_id = _to_nist_id(cas_or_id)
|
|
93
|
+
return f"{_NIST_BASE}?JCAMP={nist_id}&Index={index}&Type=IR-SPEC"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def search_url(query: str, by: str = "name") -> str:
|
|
97
|
+
"""Return a NIST WebBook search URL.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
query : str
|
|
102
|
+
Search term.
|
|
103
|
+
by : {'name', 'formula', 'cas'}, default 'name'
|
|
104
|
+
Search type.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
str
|
|
109
|
+
NIST WebBook search URL (open in a browser or via requests).
|
|
110
|
+
"""
|
|
111
|
+
query_enc = query.replace(" ", "+")
|
|
112
|
+
if by == "formula":
|
|
113
|
+
return f"{_NIST_BASE}?Formula={query_enc}&Units=SI&cST=on"
|
|
114
|
+
elif by == "cas":
|
|
115
|
+
return get_compound_url(query)
|
|
116
|
+
else:
|
|
117
|
+
return f"{_NIST_BASE}?Name={query_enc}&Units=SI&cST=on"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def fetch_ir_spectrum(cas_or_id: str, index: int = 0) -> Any:
|
|
121
|
+
"""Download the IR spectrum for a compound from NIST WebBook.
|
|
122
|
+
|
|
123
|
+
Fetches the JCAMP-DX file for the given compound and returns it as a
|
|
124
|
+
``pd.DataFrame`` with columns ``x`` (wavenumber, cm⁻¹) and ``y``
|
|
125
|
+
(absorbance or transmittance). Compound metadata is stored in
|
|
126
|
+
``df.attrs``.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
cas_or_id : str
|
|
131
|
+
CAS Registry Number (e.g. ``'64-17-5'``) or NIST compound ID.
|
|
132
|
+
index : int, default 0
|
|
133
|
+
Spectrum index when multiple IR spectra are available.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
pd.DataFrame
|
|
138
|
+
Columns: ``x`` (wavenumber, cm⁻¹), ``y`` (absorbance / transmittance).
|
|
139
|
+
``df.attrs`` contains JCAMP metadata (``TITLE``, ``DATA TYPE``, etc.).
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
ImportError
|
|
144
|
+
If ``requests`` is not installed.
|
|
145
|
+
RuntimeError
|
|
146
|
+
If the NIST server returns a non-200 status or no spectrum is found.
|
|
147
|
+
|
|
148
|
+
Examples
|
|
149
|
+
--------
|
|
150
|
+
>>> df = fetch_ir_spectrum('64-17-5') # ethanol
|
|
151
|
+
>>> df.columns.tolist()
|
|
152
|
+
['x', 'y']
|
|
153
|
+
>>> df.attrs.get('TITLE')
|
|
154
|
+
'Ethanol'
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
import requests
|
|
158
|
+
except ImportError as exc:
|
|
159
|
+
raise ImportError(
|
|
160
|
+
"requests is required for NIST WebBook access. Install it with: pip install requests"
|
|
161
|
+
) from exc
|
|
162
|
+
|
|
163
|
+
from eagleplot.io import load_jcamp
|
|
164
|
+
|
|
165
|
+
url = get_ir_jcamp_url(cas_or_id, index)
|
|
166
|
+
resp = requests.get(url, timeout=15)
|
|
167
|
+
if resp.status_code != 200:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"NIST WebBook returned HTTP {resp.status_code} for CAS '{cas_or_id}'. "
|
|
170
|
+
"Check that the CAS number is correct and the spectrum exists."
|
|
171
|
+
)
|
|
172
|
+
content = resp.text
|
|
173
|
+
if "##TITLE=" not in content and "##title=" not in content.lower():
|
|
174
|
+
raise RuntimeError(
|
|
175
|
+
f"No IR spectrum found for CAS '{cas_or_id}' (index={index}). "
|
|
176
|
+
"The compound may not have an IR spectrum in NIST WebBook."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Write to a temp file so load_jcamp can parse it
|
|
180
|
+
with tempfile.NamedTemporaryFile(
|
|
181
|
+
mode="w", suffix=".jdx", delete=False, encoding="utf-8"
|
|
182
|
+
) as tmp:
|
|
183
|
+
tmp.write(content)
|
|
184
|
+
tmp_path = tmp.name
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
df = load_jcamp(tmp_path)
|
|
188
|
+
finally:
|
|
189
|
+
os.unlink(tmp_path)
|
|
190
|
+
|
|
191
|
+
df.attrs["nist_cas"] = cas_or_id
|
|
192
|
+
df.attrs["nist_url"] = get_compound_url(cas_or_id)
|
|
193
|
+
return df
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def list_ir_spectra(cas_or_id: str) -> List[Dict[str, Any]]:
|
|
197
|
+
"""List available IR spectra for a compound on NIST WebBook.
|
|
198
|
+
|
|
199
|
+
Attempts to fetch the first few spectrum indices (0–4) and returns
|
|
200
|
+
metadata for each one that exists.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
cas_or_id : str
|
|
205
|
+
CAS Registry Number or NIST compound ID.
|
|
206
|
+
|
|
207
|
+
Returns
|
|
208
|
+
-------
|
|
209
|
+
list of dict
|
|
210
|
+
Each dict has keys: ``index`` (int), ``url`` (str),
|
|
211
|
+
``title`` (str), ``data_type`` (str).
|
|
212
|
+
Empty list if no spectra are found or ``requests`` is not available.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
import requests
|
|
216
|
+
except ImportError:
|
|
217
|
+
return []
|
|
218
|
+
|
|
219
|
+
results = []
|
|
220
|
+
for idx in range(5):
|
|
221
|
+
url = get_ir_jcamp_url(cas_or_id, idx)
|
|
222
|
+
try:
|
|
223
|
+
resp = requests.get(url, timeout=10)
|
|
224
|
+
if resp.status_code != 200:
|
|
225
|
+
break
|
|
226
|
+
content = resp.text
|
|
227
|
+
if "##TITLE=" not in content and "##title=" not in content.lower():
|
|
228
|
+
break
|
|
229
|
+
title = ""
|
|
230
|
+
data_type = ""
|
|
231
|
+
for line in content.splitlines():
|
|
232
|
+
if line.upper().startswith("##TITLE="):
|
|
233
|
+
title = line.split("=", 1)[1].strip()
|
|
234
|
+
elif line.upper().startswith("##DATA TYPE="):
|
|
235
|
+
data_type = line.split("=", 1)[1].strip()
|
|
236
|
+
if title and data_type:
|
|
237
|
+
break
|
|
238
|
+
results.append({"index": idx, "url": url, "title": title, "data_type": data_type})
|
|
239
|
+
except Exception:
|
|
240
|
+
break
|
|
241
|
+
return results
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Molecular Visualization page.
|
|
2
|
+
|
|
3
|
+
Upload a Gaussian (.log), ORCA (.out), or Molden (.molden) output file,
|
|
4
|
+
parse the vibrational frequency data, and visualize the molecular structure
|
|
5
|
+
and normal mode displacement vectors interactively in 3D using Plotly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import streamlit as st
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
19
|
+
|
|
20
|
+
from eagleplot.utils.session_state import initialize_session_state
|
|
21
|
+
from eagleplot.molecular import (
|
|
22
|
+
VibrationalData,
|
|
23
|
+
build_molecule_figure,
|
|
24
|
+
parse_vibrations,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
initialize_session_state()
|
|
28
|
+
|
|
29
|
+
st.title("Molecular Visualization")
|
|
30
|
+
st.caption(
|
|
31
|
+
"Upload a quantum chemistry output file to visualize molecular structure "
|
|
32
|
+
"and normal-mode vibrational displacements in 3D."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
# File uploader
|
|
37
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
uploaded = st.file_uploader(
|
|
39
|
+
"Upload a vibrational frequency output file",
|
|
40
|
+
type=["log", "out", "molden"],
|
|
41
|
+
help="Supported formats: Gaussian .log/.out, ORCA .out, Molden .molden",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if uploaded is None:
|
|
45
|
+
st.info("Upload a Gaussian `.log`, ORCA `.out`, or Molden `.molden` file to begin.")
|
|
46
|
+
st.stop()
|
|
47
|
+
|
|
48
|
+
# ── Parse the uploaded file ────────────────────────────────────────────────
|
|
49
|
+
ext = Path(uploaded.name).suffix.lower()
|
|
50
|
+
suffix_map = {".log": ".log", ".out": ".out", ".molden": ".molden"}
|
|
51
|
+
file_suffix = suffix_map.get(ext, ".out")
|
|
52
|
+
|
|
53
|
+
tmp_path = None
|
|
54
|
+
vib_data: VibrationalData | None = st.session_state.mol_vib_data
|
|
55
|
+
|
|
56
|
+
# Re-parse if the uploaded filename changed (or first load).
|
|
57
|
+
if vib_data is None or st.session_state.get("mol_vib_filename") != uploaded.name:
|
|
58
|
+
with tempfile.NamedTemporaryFile(suffix=file_suffix, mode="wb", delete=False) as tmp:
|
|
59
|
+
tmp.write(uploaded.read())
|
|
60
|
+
tmp_path = tmp.name
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
vib_data = parse_vibrations(tmp_path)
|
|
64
|
+
st.session_state.mol_vib_data = vib_data
|
|
65
|
+
st.session_state["mol_vib_filename"] = uploaded.name
|
|
66
|
+
st.success(
|
|
67
|
+
f"Parsed **{uploaded.name}** — {vib_data.program.title()} format, "
|
|
68
|
+
f"{len(vib_data.atomic_numbers)} atoms, {len(vib_data.modes)} modes."
|
|
69
|
+
)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
st.error(f"Failed to parse file: {exc}")
|
|
72
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
73
|
+
os.unlink(tmp_path)
|
|
74
|
+
st.stop()
|
|
75
|
+
finally:
|
|
76
|
+
if tmp_path and os.path.exists(tmp_path):
|
|
77
|
+
os.unlink(tmp_path)
|
|
78
|
+
|
|
79
|
+
if vib_data is None:
|
|
80
|
+
st.stop()
|
|
81
|
+
|
|
82
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
# Summary table
|
|
84
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
with st.expander("Vibrational Frequency Summary", expanded=False):
|
|
86
|
+
if vib_data.modes:
|
|
87
|
+
freq_df = pd.DataFrame(
|
|
88
|
+
{
|
|
89
|
+
"Mode #": [m.mode_number for m in vib_data.modes],
|
|
90
|
+
"Frequency (cm⁻¹)": [m.frequency for m in vib_data.modes],
|
|
91
|
+
"IR Intensity (km/mol)": [
|
|
92
|
+
(f"{m.ir_intensity:.2f}" if m.ir_intensity is not None else "—")
|
|
93
|
+
for m in vib_data.modes
|
|
94
|
+
],
|
|
95
|
+
"Imaginary": ["Yes" if m.is_imaginary else "No" for m in vib_data.modes],
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
st.dataframe(freq_df, width="stretch")
|
|
99
|
+
else:
|
|
100
|
+
st.info("No vibrational modes found in the file.")
|
|
101
|
+
|
|
102
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
# Visualization controls
|
|
104
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
st.subheader("3D Molecular Structure")
|
|
106
|
+
|
|
107
|
+
show_arrows = st.checkbox("Show vibrational displacement arrows", value=False)
|
|
108
|
+
|
|
109
|
+
mode_number = None
|
|
110
|
+
arrow_color = "red"
|
|
111
|
+
arrow_scale = 1.0
|
|
112
|
+
amplitude = 1.0
|
|
113
|
+
|
|
114
|
+
if show_arrows and vib_data.modes:
|
|
115
|
+
mode_numbers = [m.mode_number for m in vib_data.modes]
|
|
116
|
+
freqs = [m.frequency for m in vib_data.modes]
|
|
117
|
+
mode_labels = [f"Mode {n}: {f:.1f} cm⁻¹" for n, f in zip(mode_numbers, freqs)]
|
|
118
|
+
|
|
119
|
+
selected_label = st.selectbox("Vibrational mode", mode_labels, key="mol_mode_sel")
|
|
120
|
+
mode_number = mode_numbers[mode_labels.index(selected_label)]
|
|
121
|
+
|
|
122
|
+
col1, col2, col3 = st.columns(3)
|
|
123
|
+
with col1:
|
|
124
|
+
arrow_color = st.color_picker("Arrow color", value="#FF0000", key="mol_arrow_color")
|
|
125
|
+
with col2:
|
|
126
|
+
arrow_scale = st.slider(
|
|
127
|
+
"Arrow visual scale",
|
|
128
|
+
min_value=0.1,
|
|
129
|
+
max_value=5.0,
|
|
130
|
+
value=1.0,
|
|
131
|
+
step=0.1,
|
|
132
|
+
key="mol_arrow_scale",
|
|
133
|
+
)
|
|
134
|
+
with col3:
|
|
135
|
+
amplitude = st.slider(
|
|
136
|
+
"Displacement amplitude",
|
|
137
|
+
min_value=0.1,
|
|
138
|
+
max_value=5.0,
|
|
139
|
+
value=1.0,
|
|
140
|
+
step=0.1,
|
|
141
|
+
key="mol_amplitude",
|
|
142
|
+
)
|
|
143
|
+
elif show_arrows and not vib_data.modes:
|
|
144
|
+
st.warning("No vibrational modes available in this file.")
|
|
145
|
+
|
|
146
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
# Build and display figure
|
|
148
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
try:
|
|
150
|
+
fig = build_molecule_figure(
|
|
151
|
+
vib_data,
|
|
152
|
+
mode_number=mode_number,
|
|
153
|
+
arrow_color=arrow_color,
|
|
154
|
+
arrow_scale=float(arrow_scale),
|
|
155
|
+
amplitude=float(amplitude),
|
|
156
|
+
)
|
|
157
|
+
title_text = uploaded.name
|
|
158
|
+
if mode_number is not None:
|
|
159
|
+
mode_obj = vib_data.get_mode(mode_number)
|
|
160
|
+
if mode_obj:
|
|
161
|
+
title_text += f" — Mode {mode_number} ({mode_obj.frequency:.1f} cm⁻¹)"
|
|
162
|
+
fig.update_layout(title=title_text, height=600)
|
|
163
|
+
st.plotly_chart(fig, width="stretch")
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
st.error(f"Error building figure: {exc}")
|
|
166
|
+
st.stop()
|
|
167
|
+
|
|
168
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
# Export
|
|
170
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
st.subheader("Export")
|
|
172
|
+
|
|
173
|
+
col1, col2 = st.columns(2)
|
|
174
|
+
|
|
175
|
+
with col1:
|
|
176
|
+
if st.button("Export as interactive HTML", key="mol_export_html"):
|
|
177
|
+
try:
|
|
178
|
+
html_str = fig.to_html(full_html=True, include_plotlyjs="cdn")
|
|
179
|
+
st.download_button(
|
|
180
|
+
"Download HTML",
|
|
181
|
+
data=html_str,
|
|
182
|
+
file_name=f"{Path(uploaded.name).stem}_mol.html",
|
|
183
|
+
mime="text/html",
|
|
184
|
+
key="mol_dl_html",
|
|
185
|
+
)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
st.error(str(exc))
|
|
188
|
+
|
|
189
|
+
with col2:
|
|
190
|
+
if vib_data.modes and st.button("Download frequency table (CSV)", key="mol_dl_csv"):
|
|
191
|
+
freq_df_dl = pd.DataFrame(
|
|
192
|
+
{
|
|
193
|
+
"mode": [m.mode_number for m in vib_data.modes],
|
|
194
|
+
"frequency_cm-1": [m.frequency for m in vib_data.modes],
|
|
195
|
+
"ir_intensity_km_per_mol": [
|
|
196
|
+
(m.ir_intensity if m.ir_intensity is not None else float("nan"))
|
|
197
|
+
for m in vib_data.modes
|
|
198
|
+
],
|
|
199
|
+
"is_imaginary": [m.is_imaginary for m in vib_data.modes],
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
csv_bytes = freq_df_dl.to_csv(index=False).encode()
|
|
203
|
+
st.download_button(
|
|
204
|
+
"Download CSV",
|
|
205
|
+
data=csv_bytes,
|
|
206
|
+
file_name=f"{Path(uploaded.name).stem}_frequencies.csv",
|
|
207
|
+
mime="text/csv",
|
|
208
|
+
key="mol_dl_csv_btn",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
212
|
+
# Atom coordinates table
|
|
213
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
with st.expander("Atomic Coordinates", expanded=False):
|
|
215
|
+
from eagleplot.molecular.atom_data import get_atom_symbol
|
|
216
|
+
|
|
217
|
+
coord_df = pd.DataFrame(
|
|
218
|
+
{
|
|
219
|
+
"Atom": [get_atom_symbol(z) for z in vib_data.atomic_numbers],
|
|
220
|
+
"X (Å)": vib_data.coordinates[:, 0],
|
|
221
|
+
"Y (Å)": vib_data.coordinates[:, 1],
|
|
222
|
+
"Z (Å)": vib_data.coordinates[:, 2],
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
st.dataframe(coord_df, width="stretch")
|