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.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. 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")