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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Plugin loader for EaglePlot.
|
|
2
|
+
|
|
3
|
+
Discovers and loads plugin_*.py files from the plugins/ directory.
|
|
4
|
+
Each plugin is a standard Python module that optionally exposes:
|
|
5
|
+
|
|
6
|
+
PLUGIN_NAME: str — human-readable name
|
|
7
|
+
PLUGIN_VERSION: str — semver string
|
|
8
|
+
PLUGIN_DESCRIPTION: str — one-line description
|
|
9
|
+
|
|
10
|
+
get_plot_types() -> list[dict]
|
|
11
|
+
Returns a list of plot-type registration dicts, each with keys:
|
|
12
|
+
- 'name': str (unique identifier)
|
|
13
|
+
- 'label': str (display label)
|
|
14
|
+
- 'function': callable (plotting function)
|
|
15
|
+
- 'description': str (optional)
|
|
16
|
+
|
|
17
|
+
get_analysis_tools() -> list[dict]
|
|
18
|
+
Returns a list of analysis-tool dicts with keys:
|
|
19
|
+
- 'name': str
|
|
20
|
+
- 'label': str
|
|
21
|
+
- 'function': callable
|
|
22
|
+
- 'description': str (optional)
|
|
23
|
+
|
|
24
|
+
Public API
|
|
25
|
+
----------
|
|
26
|
+
discover_plugins(plugins_dir=None) -> dict[str, types.ModuleType]
|
|
27
|
+
Scan plugins_dir (default: repo-root/plugins/) for plugin_*.py files.
|
|
28
|
+
Import each valid plugin and return {filename_stem: module}.
|
|
29
|
+
Silently skips files that raise ImportError or other exceptions during load.
|
|
30
|
+
|
|
31
|
+
get_plugin_plot_types(plugins=None) -> list[dict]
|
|
32
|
+
Return all plot-type dicts from all discovered plugins.
|
|
33
|
+
plugins: pass result of discover_plugins() to avoid re-scanning.
|
|
34
|
+
|
|
35
|
+
get_plugin_analysis_tools(plugins=None) -> list[dict]
|
|
36
|
+
Return all analysis-tool dicts from all discovered plugins.
|
|
37
|
+
|
|
38
|
+
list_plugins(plugins=None) -> list[dict]
|
|
39
|
+
Return a summary list of loaded plugins:
|
|
40
|
+
[{'name': ..., 'version': ..., 'description': ..., 'file': ...}, ...]
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import importlib.util
|
|
44
|
+
import types
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Optional
|
|
47
|
+
|
|
48
|
+
_PLUGINS_DIR = Path(__file__).parent.parent / "plugins"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def discover_plugins(
|
|
52
|
+
plugins_dir: Optional[Path] = None,
|
|
53
|
+
) -> dict[str, types.ModuleType]:
|
|
54
|
+
"""Scan plugins_dir for plugin_*.py files and import each one.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
plugins_dir : Path, optional
|
|
59
|
+
Directory to scan. Defaults to ``<repo-root>/plugins/``.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
dict[str, types.ModuleType]
|
|
64
|
+
Mapping of filename stem (e.g. ``"plugin_example"``) to the loaded
|
|
65
|
+
module object. Files that fail to import are silently skipped.
|
|
66
|
+
"""
|
|
67
|
+
if plugins_dir is None:
|
|
68
|
+
plugins_dir = _PLUGINS_DIR
|
|
69
|
+
|
|
70
|
+
plugins_dir = Path(plugins_dir)
|
|
71
|
+
|
|
72
|
+
if not plugins_dir.is_dir():
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
loaded: dict[str, types.ModuleType] = {}
|
|
76
|
+
|
|
77
|
+
for path in sorted(plugins_dir.glob("plugin_*.py")):
|
|
78
|
+
stem = path.stem
|
|
79
|
+
try:
|
|
80
|
+
spec = importlib.util.spec_from_file_location(stem, path)
|
|
81
|
+
if spec is None or spec.loader is None:
|
|
82
|
+
continue
|
|
83
|
+
module = importlib.util.module_from_spec(spec)
|
|
84
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
85
|
+
loaded[stem] = module
|
|
86
|
+
except Exception:
|
|
87
|
+
# Bad plugins must never crash the app
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return loaded
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_plugin_plot_types(
|
|
94
|
+
plugins: Optional[dict[str, types.ModuleType]] = None,
|
|
95
|
+
) -> list[dict]:
|
|
96
|
+
"""Return all plot-type dicts contributed by discovered plugins.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
plugins : dict, optional
|
|
101
|
+
Pre-loaded plugins dict from :func:`discover_plugins`. If *None*,
|
|
102
|
+
:func:`discover_plugins` is called automatically.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
list[dict]
|
|
107
|
+
Concatenated list of plot-type dicts from every plugin that exposes
|
|
108
|
+
``get_plot_types()``. Each dict has at minimum ``name``, ``label``,
|
|
109
|
+
and ``function`` keys.
|
|
110
|
+
"""
|
|
111
|
+
if plugins is None:
|
|
112
|
+
plugins = discover_plugins()
|
|
113
|
+
|
|
114
|
+
result: list[dict] = []
|
|
115
|
+
|
|
116
|
+
for module in plugins.values():
|
|
117
|
+
if not hasattr(module, "get_plot_types"):
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
items = module.get_plot_types()
|
|
121
|
+
if isinstance(items, list):
|
|
122
|
+
result.extend(items)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_plugin_analysis_tools(
|
|
130
|
+
plugins: Optional[dict[str, types.ModuleType]] = None,
|
|
131
|
+
) -> list[dict]:
|
|
132
|
+
"""Return all analysis-tool dicts contributed by discovered plugins.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
plugins : dict, optional
|
|
137
|
+
Pre-loaded plugins dict from :func:`discover_plugins`. If *None*,
|
|
138
|
+
:func:`discover_plugins` is called automatically.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
list[dict]
|
|
143
|
+
Concatenated list of analysis-tool dicts from every plugin that
|
|
144
|
+
exposes ``get_analysis_tools()``. Each dict has at minimum ``name``,
|
|
145
|
+
``label``, and ``function`` keys.
|
|
146
|
+
"""
|
|
147
|
+
if plugins is None:
|
|
148
|
+
plugins = discover_plugins()
|
|
149
|
+
|
|
150
|
+
result: list[dict] = []
|
|
151
|
+
|
|
152
|
+
for module in plugins.values():
|
|
153
|
+
if not hasattr(module, "get_analysis_tools"):
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
items = module.get_analysis_tools()
|
|
157
|
+
if isinstance(items, list):
|
|
158
|
+
result.extend(items)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_plugins(
|
|
166
|
+
plugins: Optional[dict[str, types.ModuleType]] = None,
|
|
167
|
+
) -> list[dict]:
|
|
168
|
+
"""Return a human-readable summary of all loaded plugins.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
plugins : dict, optional
|
|
173
|
+
Pre-loaded plugins dict from :func:`discover_plugins`. If *None*,
|
|
174
|
+
:func:`discover_plugins` is called automatically.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
list[dict]
|
|
179
|
+
One dict per plugin with keys:
|
|
180
|
+
|
|
181
|
+
- ``name`` — ``PLUGIN_NAME`` attribute, or the file stem if absent
|
|
182
|
+
- ``version`` — ``PLUGIN_VERSION`` attribute, or ``"unknown"``
|
|
183
|
+
- ``description`` — ``PLUGIN_DESCRIPTION`` attribute, or ``""``
|
|
184
|
+
- ``file`` — absolute :class:`~pathlib.Path` to the plugin file
|
|
185
|
+
"""
|
|
186
|
+
if plugins is None:
|
|
187
|
+
plugins = discover_plugins()
|
|
188
|
+
|
|
189
|
+
summary: list[dict] = []
|
|
190
|
+
|
|
191
|
+
for stem, module in plugins.items():
|
|
192
|
+
name = getattr(module, "PLUGIN_NAME", stem)
|
|
193
|
+
version = getattr(module, "PLUGIN_VERSION", "unknown")
|
|
194
|
+
description = getattr(module, "PLUGIN_DESCRIPTION", "")
|
|
195
|
+
|
|
196
|
+
# Retrieve the file path from the module's __file__ attribute
|
|
197
|
+
file_path = Path(getattr(module, "__file__", ""))
|
|
198
|
+
|
|
199
|
+
summary.append(
|
|
200
|
+
{
|
|
201
|
+
"name": name,
|
|
202
|
+
"version": version,
|
|
203
|
+
"description": description,
|
|
204
|
+
"file": file_path,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return summary
|
eagleplot/report.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""PDF Report Generator for EaglePlot sessions.
|
|
2
|
+
|
|
3
|
+
Builds a multi-page PDF from a session's plots, datasets, and analysis results
|
|
4
|
+
using matplotlib.backends.backend_pdf.PdfPages — no extra dependencies required.
|
|
5
|
+
|
|
6
|
+
Public API
|
|
7
|
+
----------
|
|
8
|
+
generate_pdf_report(filepath, title, plot_entries, datasets, analysis_results,
|
|
9
|
+
author='', dpi=150) -> Path
|
|
10
|
+
Write a PDF report and return the output Path.
|
|
11
|
+
|
|
12
|
+
_make_title_page(title, author, n_plots, n_datasets, n_analyses) -> Figure
|
|
13
|
+
(internal) Render a title page as a matplotlib Figure.
|
|
14
|
+
|
|
15
|
+
_dataframe_to_figure(df, title, max_rows) -> Figure
|
|
16
|
+
(internal) Render a DataFrame as a matplotlib table Figure.
|
|
17
|
+
|
|
18
|
+
_analysis_result_to_figure(result) -> Figure
|
|
19
|
+
(internal) Render one analysis result dict as a text/table Figure.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import matplotlib.pyplot as plt
|
|
28
|
+
import numpy as np
|
|
29
|
+
import pandas as pd
|
|
30
|
+
from matplotlib.backends.backend_pdf import PdfPages
|
|
31
|
+
from matplotlib.figure import Figure
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_title_page(
|
|
38
|
+
title: str,
|
|
39
|
+
author: str,
|
|
40
|
+
n_plots: int,
|
|
41
|
+
n_datasets: int,
|
|
42
|
+
n_analyses: int,
|
|
43
|
+
) -> Figure:
|
|
44
|
+
"""Render a title page as a matplotlib Figure.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
title:
|
|
49
|
+
Report title string.
|
|
50
|
+
author:
|
|
51
|
+
Author name (may be empty).
|
|
52
|
+
n_plots:
|
|
53
|
+
Number of matplotlib plot entries included in the report.
|
|
54
|
+
n_datasets:
|
|
55
|
+
Number of DataFrame datasets included in the report.
|
|
56
|
+
n_analyses:
|
|
57
|
+
Number of analysis result entries included in the report.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
Figure
|
|
62
|
+
A matplotlib Figure (caller is responsible for closing it).
|
|
63
|
+
"""
|
|
64
|
+
fig = plt.figure(figsize=(8.5, 11), facecolor="white")
|
|
65
|
+
ax = fig.add_axes((0.0, 0.0, 1.0, 1.0))
|
|
66
|
+
ax.set_axis_off()
|
|
67
|
+
|
|
68
|
+
# Decorative horizontal rules
|
|
69
|
+
ax.axhline(0.78, xmin=0.08, xmax=0.92, color="#333333", linewidth=1.5)
|
|
70
|
+
ax.axhline(0.20, xmin=0.08, xmax=0.92, color="#333333", linewidth=0.8)
|
|
71
|
+
|
|
72
|
+
# Title
|
|
73
|
+
fig.text(
|
|
74
|
+
0.5,
|
|
75
|
+
0.65,
|
|
76
|
+
title,
|
|
77
|
+
ha="center",
|
|
78
|
+
va="center",
|
|
79
|
+
fontsize=24,
|
|
80
|
+
fontweight="bold",
|
|
81
|
+
color="#111111",
|
|
82
|
+
wrap=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Subtitle
|
|
86
|
+
fig.text(
|
|
87
|
+
0.5,
|
|
88
|
+
0.57,
|
|
89
|
+
"Generated by EaglePlot",
|
|
90
|
+
ha="center",
|
|
91
|
+
va="center",
|
|
92
|
+
fontsize=13,
|
|
93
|
+
color="#555555",
|
|
94
|
+
style="italic",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Author
|
|
98
|
+
if author:
|
|
99
|
+
fig.text(
|
|
100
|
+
0.5,
|
|
101
|
+
0.50,
|
|
102
|
+
f"Author: {author}",
|
|
103
|
+
ha="center",
|
|
104
|
+
va="center",
|
|
105
|
+
fontsize=11,
|
|
106
|
+
color="#333333",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Date
|
|
110
|
+
date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
111
|
+
fig.text(
|
|
112
|
+
0.5,
|
|
113
|
+
0.44,
|
|
114
|
+
f"Date: {date_str}",
|
|
115
|
+
ha="center",
|
|
116
|
+
va="center",
|
|
117
|
+
fontsize=11,
|
|
118
|
+
color="#333333",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Contents summary
|
|
122
|
+
fig.text(
|
|
123
|
+
0.5,
|
|
124
|
+
0.34,
|
|
125
|
+
"Contents",
|
|
126
|
+
ha="center",
|
|
127
|
+
va="center",
|
|
128
|
+
fontsize=13,
|
|
129
|
+
fontweight="bold",
|
|
130
|
+
color="#111111",
|
|
131
|
+
)
|
|
132
|
+
summary_lines = [
|
|
133
|
+
f"Plots (matplotlib): {n_plots}",
|
|
134
|
+
f"Datasets: {n_datasets}",
|
|
135
|
+
f"Analysis results: {n_analyses}",
|
|
136
|
+
]
|
|
137
|
+
fig.text(
|
|
138
|
+
0.5,
|
|
139
|
+
0.27,
|
|
140
|
+
"\n".join(summary_lines),
|
|
141
|
+
ha="center",
|
|
142
|
+
va="center",
|
|
143
|
+
fontsize=11,
|
|
144
|
+
color="#333333",
|
|
145
|
+
family="monospace",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Footer
|
|
149
|
+
fig.text(
|
|
150
|
+
0.5,
|
|
151
|
+
0.08,
|
|
152
|
+
"NCCU Schultz Lab · EaglePlot",
|
|
153
|
+
ha="center",
|
|
154
|
+
va="center",
|
|
155
|
+
fontsize=9,
|
|
156
|
+
color="#888888",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return fig
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _dataframe_to_figure(
|
|
163
|
+
df: pd.DataFrame,
|
|
164
|
+
title: str = "",
|
|
165
|
+
max_rows: int = 30,
|
|
166
|
+
) -> Figure:
|
|
167
|
+
"""Render a DataFrame as a matplotlib table Figure.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
df:
|
|
172
|
+
The DataFrame to display.
|
|
173
|
+
title:
|
|
174
|
+
Title shown above the table.
|
|
175
|
+
max_rows:
|
|
176
|
+
Maximum number of data rows to render (additional rows are omitted
|
|
177
|
+
with a note).
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
Figure
|
|
182
|
+
A matplotlib Figure (caller is responsible for closing it).
|
|
183
|
+
"""
|
|
184
|
+
max_cols = 10
|
|
185
|
+
|
|
186
|
+
truncated_rows = len(df) > max_rows
|
|
187
|
+
truncated_cols = len(df.columns) > max_cols
|
|
188
|
+
|
|
189
|
+
display_df = df.iloc[:max_rows, :max_cols].copy()
|
|
190
|
+
|
|
191
|
+
# Convert all values to short strings for display
|
|
192
|
+
for col in display_df.columns:
|
|
193
|
+
display_df[col] = display_df[col].apply(
|
|
194
|
+
lambda v: f"{v:.4g}" if isinstance(v, (float, np.floating)) else str(v)[:20]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
n_rows, n_cols = display_df.shape
|
|
198
|
+
|
|
199
|
+
# Figure height: scale with number of rows, bounded
|
|
200
|
+
row_height = 0.35
|
|
201
|
+
fig_height = max(3.0, min(14.0, 1.5 + n_rows * row_height))
|
|
202
|
+
fig = plt.figure(figsize=(11, fig_height), facecolor="white")
|
|
203
|
+
ax = fig.add_axes((0.02, 0.02, 0.96, 0.96))
|
|
204
|
+
ax.set_axis_off()
|
|
205
|
+
|
|
206
|
+
# Title
|
|
207
|
+
notes: list[str] = []
|
|
208
|
+
if truncated_rows:
|
|
209
|
+
notes.append(f"showing first {max_rows} of {len(df)} rows")
|
|
210
|
+
if truncated_cols:
|
|
211
|
+
notes.append(f"showing first {max_cols} of {len(df.columns)} columns")
|
|
212
|
+
note_str = f" ({', '.join(notes)})" if notes else ""
|
|
213
|
+
ax.set_title(
|
|
214
|
+
f"{title}{note_str}",
|
|
215
|
+
fontsize=12,
|
|
216
|
+
fontweight="bold",
|
|
217
|
+
pad=8,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if display_df.empty:
|
|
221
|
+
ax.text(
|
|
222
|
+
0.5,
|
|
223
|
+
0.5,
|
|
224
|
+
"(empty DataFrame)",
|
|
225
|
+
ha="center",
|
|
226
|
+
va="center",
|
|
227
|
+
fontsize=11,
|
|
228
|
+
color="#888888",
|
|
229
|
+
transform=ax.transAxes,
|
|
230
|
+
)
|
|
231
|
+
return fig
|
|
232
|
+
|
|
233
|
+
col_labels = list(display_df.columns)
|
|
234
|
+
cell_data = display_df.values.tolist()
|
|
235
|
+
|
|
236
|
+
table = ax.table(
|
|
237
|
+
cellText=cell_data,
|
|
238
|
+
colLabels=col_labels,
|
|
239
|
+
loc="center",
|
|
240
|
+
cellLoc="center",
|
|
241
|
+
)
|
|
242
|
+
table.auto_set_font_size(False)
|
|
243
|
+
table.set_fontsize(8)
|
|
244
|
+
table.auto_set_column_width(list(range(n_cols)))
|
|
245
|
+
|
|
246
|
+
# Style header row
|
|
247
|
+
for col_idx in range(n_cols):
|
|
248
|
+
cell = table[0, col_idx]
|
|
249
|
+
cell.set_facecolor("#2c5f8a")
|
|
250
|
+
cell.set_text_props(color="white", fontweight="bold")
|
|
251
|
+
|
|
252
|
+
# Alternate row shading
|
|
253
|
+
for row_idx in range(1, n_rows + 1):
|
|
254
|
+
bg = "#f0f4f8" if row_idx % 2 == 0 else "white"
|
|
255
|
+
for col_idx in range(n_cols):
|
|
256
|
+
table[row_idx, col_idx].set_facecolor(bg)
|
|
257
|
+
|
|
258
|
+
return fig
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _analysis_result_to_figure(result: dict) -> Figure:
|
|
262
|
+
"""Render one analysis result dict as a text Figure.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
result:
|
|
267
|
+
A dict with keys ``type``, ``dataset``, ``timestamp``, and
|
|
268
|
+
``results`` (the actual analysis output dict).
|
|
269
|
+
|
|
270
|
+
Returns
|
|
271
|
+
-------
|
|
272
|
+
Figure
|
|
273
|
+
A matplotlib Figure (caller is responsible for closing it).
|
|
274
|
+
"""
|
|
275
|
+
fig = plt.figure(figsize=(8.5, 6), facecolor="white")
|
|
276
|
+
ax = fig.add_axes((0.05, 0.05, 0.90, 0.90))
|
|
277
|
+
ax.set_axis_off()
|
|
278
|
+
|
|
279
|
+
r_type = result.get("type", "analysis")
|
|
280
|
+
r_dataset = result.get("dataset", "—")
|
|
281
|
+
r_ts = result.get("timestamp", "")[:19].replace("T", " ")
|
|
282
|
+
|
|
283
|
+
heading = f"{r_type} · dataset: {r_dataset} · {r_ts}"
|
|
284
|
+
ax.set_title(heading, fontsize=11, fontweight="bold", pad=10)
|
|
285
|
+
|
|
286
|
+
ax.plot([0, 1], [0.96, 0.96], color="#cccccc", linewidth=0.8, transform=ax.transAxes)
|
|
287
|
+
|
|
288
|
+
raw_results = result.get("results", {})
|
|
289
|
+
|
|
290
|
+
def _fmt_value(v) -> str:
|
|
291
|
+
"""Convert a value to a display string, skipping callables."""
|
|
292
|
+
if callable(v):
|
|
293
|
+
return "<function>"
|
|
294
|
+
if isinstance(v, np.ndarray):
|
|
295
|
+
if v.size <= 8:
|
|
296
|
+
return np.array2string(v, precision=5, suppress_small=True)
|
|
297
|
+
return f"array(shape={v.shape}, dtype={v.dtype})"
|
|
298
|
+
if isinstance(v, float):
|
|
299
|
+
return f"{v:.6g}"
|
|
300
|
+
if isinstance(v, dict):
|
|
301
|
+
return (
|
|
302
|
+
"{" + ", ".join(f"{k}: {_fmt_value(val)}" for k, val in list(v.items())[:6]) + "}"
|
|
303
|
+
)
|
|
304
|
+
if isinstance(v, (list, tuple)) and len(v) > 8:
|
|
305
|
+
return f"[{type(v).__name__} of length {len(v)}]"
|
|
306
|
+
return str(v)[:120]
|
|
307
|
+
|
|
308
|
+
if not raw_results:
|
|
309
|
+
ax.text(
|
|
310
|
+
0.5,
|
|
311
|
+
0.5,
|
|
312
|
+
"(no results data)",
|
|
313
|
+
ha="center",
|
|
314
|
+
va="center",
|
|
315
|
+
fontsize=10,
|
|
316
|
+
color="#888888",
|
|
317
|
+
transform=ax.transAxes,
|
|
318
|
+
)
|
|
319
|
+
return fig
|
|
320
|
+
|
|
321
|
+
lines: list[str] = []
|
|
322
|
+
for key, val in raw_results.items():
|
|
323
|
+
if callable(val):
|
|
324
|
+
continue
|
|
325
|
+
lines.append(f"{key}: {_fmt_value(val)}")
|
|
326
|
+
|
|
327
|
+
# Render as evenly-spaced text lines
|
|
328
|
+
n = len(lines)
|
|
329
|
+
y_start = 0.88
|
|
330
|
+
y_step = min(0.07, 0.80 / max(n, 1))
|
|
331
|
+
for i, line in enumerate(lines):
|
|
332
|
+
ax.text(
|
|
333
|
+
0.02,
|
|
334
|
+
y_start - i * y_step,
|
|
335
|
+
line,
|
|
336
|
+
fontsize=9,
|
|
337
|
+
family="monospace",
|
|
338
|
+
va="top",
|
|
339
|
+
transform=ax.transAxes,
|
|
340
|
+
color="#222222",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return fig
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def generate_pdf_report(
|
|
350
|
+
filepath: str | Path,
|
|
351
|
+
title: str,
|
|
352
|
+
plot_entries: list[dict],
|
|
353
|
+
datasets: dict[str, pd.DataFrame],
|
|
354
|
+
analysis_results: list[dict],
|
|
355
|
+
author: str = "",
|
|
356
|
+
dpi: int = 150,
|
|
357
|
+
) -> Path:
|
|
358
|
+
"""Write a PDF report and return the output Path.
|
|
359
|
+
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
filepath:
|
|
363
|
+
Destination path for the PDF file.
|
|
364
|
+
title:
|
|
365
|
+
Report title string (shown on the title page).
|
|
366
|
+
plot_entries:
|
|
367
|
+
List of plot history dicts (from ``st.session_state.plot_history``).
|
|
368
|
+
Each dict may have keys ``type``, ``dataset``, ``timestamp``, and
|
|
369
|
+
``figure`` (a matplotlib Figure, a Plotly Figure, or None).
|
|
370
|
+
Plotly figures and ``None`` entries are silently skipped.
|
|
371
|
+
datasets:
|
|
372
|
+
Mapping of dataset name → value. Only ``pd.DataFrame`` values are
|
|
373
|
+
included; other types are skipped.
|
|
374
|
+
analysis_results:
|
|
375
|
+
List of analysis result dicts (from ``st.session_state.analysis_results``).
|
|
376
|
+
Each dict should have keys ``type``, ``dataset``, ``timestamp``, and
|
|
377
|
+
``results``.
|
|
378
|
+
author:
|
|
379
|
+
Optional author name for the title page and PDF metadata.
|
|
380
|
+
dpi:
|
|
381
|
+
Resolution used when rasterising matplotlib figures (default 150).
|
|
382
|
+
|
|
383
|
+
Returns
|
|
384
|
+
-------
|
|
385
|
+
Path
|
|
386
|
+
The path of the written PDF file.
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
from matplotlib.figure import Figure as MplFigure
|
|
390
|
+
|
|
391
|
+
_mpl_figure_cls: type | None = MplFigure
|
|
392
|
+
except ImportError:
|
|
393
|
+
_mpl_figure_cls = None
|
|
394
|
+
|
|
395
|
+
# Count how many matplotlib plots will actually be written
|
|
396
|
+
mpl_plot_count = sum(
|
|
397
|
+
1
|
|
398
|
+
for e in plot_entries
|
|
399
|
+
if _mpl_figure_cls is not None and isinstance(e.get("figure"), _mpl_figure_cls)
|
|
400
|
+
)
|
|
401
|
+
df_count = sum(1 for v in datasets.values() if isinstance(v, pd.DataFrame))
|
|
402
|
+
analysis_count = len(analysis_results)
|
|
403
|
+
|
|
404
|
+
filepath = Path(filepath)
|
|
405
|
+
|
|
406
|
+
with PdfPages(str(filepath)) as pdf:
|
|
407
|
+
# ── Title page ────────────────────────────────────────────────────
|
|
408
|
+
title_fig = _make_title_page(
|
|
409
|
+
title=title,
|
|
410
|
+
author=author,
|
|
411
|
+
n_plots=mpl_plot_count,
|
|
412
|
+
n_datasets=df_count,
|
|
413
|
+
n_analyses=analysis_count,
|
|
414
|
+
)
|
|
415
|
+
pdf.savefig(title_fig, dpi=dpi, bbox_inches="tight")
|
|
416
|
+
plt.close(title_fig)
|
|
417
|
+
|
|
418
|
+
# ── Plot pages ────────────────────────────────────────────────────
|
|
419
|
+
for entry in plot_entries:
|
|
420
|
+
fig = entry.get("figure")
|
|
421
|
+
if fig is None:
|
|
422
|
+
continue
|
|
423
|
+
if _mpl_figure_cls is not None and isinstance(fig, _mpl_figure_cls):
|
|
424
|
+
pdf.savefig(fig, dpi=dpi, bbox_inches="tight")
|
|
425
|
+
# Plotly figures are silently skipped
|
|
426
|
+
|
|
427
|
+
# ── Dataset pages ─────────────────────────────────────────────────
|
|
428
|
+
for name, data in datasets.items():
|
|
429
|
+
if not isinstance(data, pd.DataFrame):
|
|
430
|
+
continue
|
|
431
|
+
df_fig = _dataframe_to_figure(data, title=f"Dataset: {name}")
|
|
432
|
+
pdf.savefig(df_fig, dpi=dpi, bbox_inches="tight")
|
|
433
|
+
plt.close(df_fig)
|
|
434
|
+
|
|
435
|
+
# ── Analysis result pages ─────────────────────────────────────────
|
|
436
|
+
for result in analysis_results:
|
|
437
|
+
res_fig = _analysis_result_to_figure(result)
|
|
438
|
+
pdf.savefig(res_fig, dpi=dpi, bbox_inches="tight")
|
|
439
|
+
plt.close(res_fig)
|
|
440
|
+
|
|
441
|
+
# ── PDF metadata ──────────────────────────────────────────────────
|
|
442
|
+
info = pdf.infodict()
|
|
443
|
+
info["Title"] = title
|
|
444
|
+
info["Author"] = author
|
|
445
|
+
info["Subject"] = "EaglePlot session report"
|
|
446
|
+
info["Creator"] = "EaglePlot"
|
|
447
|
+
|
|
448
|
+
return filepath
|