gmxplot 0.1.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.
- gmxplot/__init__.py +3 -0
- gmxplot/cli.py +314 -0
- gmxplot/comparison.py +293 -0
- gmxplot/config.py +37 -0
- gmxplot/escape_codes.py +54 -0
- gmxplot/parser.py +247 -0
- gmxplot/plotting.py +268 -0
- gmxplot/stats.py +84 -0
- gmxplot-0.1.0.dist-info/METADATA +432 -0
- gmxplot-0.1.0.dist-info/RECORD +13 -0
- gmxplot-0.1.0.dist-info/WHEEL +4 -0
- gmxplot-0.1.0.dist-info/entry_points.txt +2 -0
- gmxplot-0.1.0.dist-info/licenses/LICENSE +8 -0
gmxplot/__init__.py
ADDED
gmxplot/cli.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Typer CLI entry point for gmxplot."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
import matplotlib
|
|
10
|
+
matplotlib.use('Agg')
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from .parser import read_xvg
|
|
17
|
+
from .plotting import create_plot, calculate_roll_avg
|
|
18
|
+
from .comparison import compare_two, compare_multi
|
|
19
|
+
from .stats import generate_stats_csv, format_stats_table
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name='gmxplot',
|
|
23
|
+
help='GROMACS XVG file visualization with auto type detection, comparison, and statistical summary.',
|
|
24
|
+
rich_markup_mode='rich',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_latex() -> bool:
|
|
31
|
+
return shutil.which('latex') is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _setup_latex(use_latex: Optional[bool] = None) -> bool:
|
|
35
|
+
if use_latex is None:
|
|
36
|
+
use_latex = False # default off — raw XVG labels often have ^ outside math mode
|
|
37
|
+
if use_latex:
|
|
38
|
+
plt.rcParams.update({
|
|
39
|
+
'text.usetex': True,
|
|
40
|
+
'font.family': 'sans-serif',
|
|
41
|
+
'font.size': 14,
|
|
42
|
+
})
|
|
43
|
+
return use_latex
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _save_figure(fig, output_path: str, dpi: int = 300) -> None:
|
|
47
|
+
path = Path(output_path)
|
|
48
|
+
suffix = path.suffix.lower()
|
|
49
|
+
if suffix not in ('.png', '.jpg', '.jpeg', '.pdf', '.svg', '.eps', '.tiff'):
|
|
50
|
+
path = path.with_suffix('.png')
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
fig.savefig(str(path), dpi=dpi, bbox_inches='tight')
|
|
53
|
+
console.print(f'Saved to: {path}')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _print_file_info(file_path: str, metadata) -> None:
|
|
57
|
+
console.print(f'File: {file_path}')
|
|
58
|
+
console.print(f' Type: [cyan]{metadata.plot_type}[/cyan]')
|
|
59
|
+
console.print(f' Title: {metadata.title}')
|
|
60
|
+
console.print(f' X-axis: {metadata.x_label}')
|
|
61
|
+
console.print(f' Y-axis: {metadata.y_label}')
|
|
62
|
+
console.print(f' Columns: {metadata.df.columns.tolist() if hasattr(metadata, "df") else "N/A"}')
|
|
63
|
+
if hasattr(metadata, 'df') and metadata.df is not None:
|
|
64
|
+
console.print(f' Data points: {len(metadata.df)}')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def plot(
|
|
69
|
+
file: str = typer.Argument(..., help='Path to XVG file'),
|
|
70
|
+
roll_avg: Optional[int] = typer.Option(None, '--roll-avg', '-r',
|
|
71
|
+
help='Rolling average window size (auto if not set)'),
|
|
72
|
+
output: Optional[str] = typer.Option(None, '--output', '-o',
|
|
73
|
+
help='Output file path (PNG, PDF, SVG, JPG)'),
|
|
74
|
+
dpi: int = typer.Option(300, '--dpi', '-d', help='DPI for saved image'),
|
|
75
|
+
show: bool = typer.Option(False, '--show', help='Display plot in window'),
|
|
76
|
+
mean: bool = typer.Option(False, '--mean', '-m', help='Plot mean line'),
|
|
77
|
+
std: bool = typer.Option(False, '--std', '-s', help='Plot mean +/- std lines'),
|
|
78
|
+
latex: Optional[bool] = typer.Option(None, '--latex/--no-latex',
|
|
79
|
+
help='Enable/disable LaTeX rendering (auto-detect if not set)'),
|
|
80
|
+
):
|
|
81
|
+
"""Plot a single XVG file with automatic type detection."""
|
|
82
|
+
if not os.path.exists(file):
|
|
83
|
+
console.print(f'[red]Error: File not found — {file}[/red]')
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
use_latex = _setup_latex(latex)
|
|
87
|
+
df, metadata = read_xvg(file)
|
|
88
|
+
|
|
89
|
+
if df.empty:
|
|
90
|
+
console.print(f'[red]Error: Could not parse data from {file}[/red]')
|
|
91
|
+
raise typer.Exit(1)
|
|
92
|
+
|
|
93
|
+
_print_file_info(file, metadata)
|
|
94
|
+
|
|
95
|
+
if roll_avg is None:
|
|
96
|
+
x_col = df.columns[0]
|
|
97
|
+
roll_avg = calculate_roll_avg(df, x_col)
|
|
98
|
+
console.print(f' Auto roll-avg: {roll_avg}')
|
|
99
|
+
|
|
100
|
+
fig = create_plot(df, metadata, roll_avg=roll_avg, show_mean=mean, show_std=std)
|
|
101
|
+
|
|
102
|
+
if output:
|
|
103
|
+
_save_figure(fig, output, dpi)
|
|
104
|
+
else:
|
|
105
|
+
stem = Path(file).stem
|
|
106
|
+
auto_output = f'{stem}_plot.png'
|
|
107
|
+
_save_figure(fig, auto_output, dpi)
|
|
108
|
+
|
|
109
|
+
if show:
|
|
110
|
+
matplotlib.use('TkAgg')
|
|
111
|
+
plt.show()
|
|
112
|
+
|
|
113
|
+
plt.close(fig)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def compare(
|
|
118
|
+
file1: str = typer.Argument(..., help='Path to first XVG file'),
|
|
119
|
+
file2: str = typer.Argument(..., help='Path to second XVG file'),
|
|
120
|
+
label1: str = typer.Option('prot_1', '--label1', '-l1', help='Label for first dataset'),
|
|
121
|
+
label2: str = typer.Option('prot_2', '--label2', '-l2', help='Label for second dataset'),
|
|
122
|
+
roll_avg: int = typer.Option(50, '--roll-avg', '-r', help='Rolling average window size'),
|
|
123
|
+
output: Optional[str] = typer.Option(None, '--output', '-o', help='Output file path'),
|
|
124
|
+
dpi: int = typer.Option(300, '--dpi', '-d', help='DPI for saved image'),
|
|
125
|
+
latex: Optional[bool] = typer.Option(None, '--latex/--no-latex',
|
|
126
|
+
help='Enable/disable LaTeX rendering'),
|
|
127
|
+
):
|
|
128
|
+
"""Compare two XVG files with auto type detection."""
|
|
129
|
+
for f in (file1, file2):
|
|
130
|
+
if not os.path.exists(f):
|
|
131
|
+
console.print(f'[red]Error: File not found — {f}[/red]')
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
_setup_latex(latex)
|
|
135
|
+
df1, meta1 = read_xvg(file1)
|
|
136
|
+
df2, meta2 = read_xvg(file2)
|
|
137
|
+
|
|
138
|
+
if df1.empty or df2.empty:
|
|
139
|
+
console.print('[red]Error: Could not parse data[/red]')
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
_print_file_info(file1, meta1)
|
|
143
|
+
_print_file_info(file2, meta2)
|
|
144
|
+
|
|
145
|
+
if meta1.plot_type != meta2.plot_type:
|
|
146
|
+
console.print(f'[yellow]Warning: Different types ({meta1.plot_type} vs {meta2.plot_type})[/yellow]')
|
|
147
|
+
|
|
148
|
+
fig = compare_two(df1, meta1, df2, meta2, label1, label2, roll_avg)
|
|
149
|
+
|
|
150
|
+
if output:
|
|
151
|
+
_save_figure(fig, output, dpi)
|
|
152
|
+
else:
|
|
153
|
+
stem1 = Path(file1).stem
|
|
154
|
+
auto_output = f'{stem1}_compare.png'
|
|
155
|
+
_save_figure(fig, auto_output, dpi)
|
|
156
|
+
|
|
157
|
+
plt.close(fig)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command(name='multi-compare')
|
|
161
|
+
def multi_compare(
|
|
162
|
+
files: List[str] = typer.Argument(..., help='Paths to XVG files (at least 2)'),
|
|
163
|
+
roll_avg: int = typer.Option(50, '--roll-avg', '-r', help='Rolling average window size'),
|
|
164
|
+
output: Optional[str] = typer.Option(None, '--output', '-o', help='Output file path'),
|
|
165
|
+
dpi: int = typer.Option(300, '--dpi', '-d', help='DPI for saved image'),
|
|
166
|
+
latex: Optional[bool] = typer.Option(None, '--latex/--no-latex',
|
|
167
|
+
help='Enable/disable LaTeX rendering'),
|
|
168
|
+
):
|
|
169
|
+
"""Compare multiple XVG files of the same type (3+ files)."""
|
|
170
|
+
valid_files = [f for f in files if os.path.exists(f)]
|
|
171
|
+
if len(valid_files) < 2:
|
|
172
|
+
console.print('[red]Error: Need at least 2 valid files[/red]')
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
|
|
175
|
+
_setup_latex(latex)
|
|
176
|
+
data_list = []
|
|
177
|
+
for f in valid_files:
|
|
178
|
+
df, meta = read_xvg(f)
|
|
179
|
+
if df.empty:
|
|
180
|
+
continue
|
|
181
|
+
label = Path(f).stem
|
|
182
|
+
data_list.append((df, meta, label))
|
|
183
|
+
|
|
184
|
+
if len(data_list) < 2:
|
|
185
|
+
console.print('[red]Error: Need at least 2 valid data files[/red]')
|
|
186
|
+
raise typer.Exit(1)
|
|
187
|
+
|
|
188
|
+
types = set(d[1].plot_type for d in data_list)
|
|
189
|
+
if len(types) > 1:
|
|
190
|
+
console.print(f'[yellow]Warning: Mixed types: {types}[/yellow]')
|
|
191
|
+
|
|
192
|
+
fig = compare_multi(data_list, roll_avg)
|
|
193
|
+
|
|
194
|
+
if output:
|
|
195
|
+
_save_figure(fig, output, dpi)
|
|
196
|
+
else:
|
|
197
|
+
auto_output = 'multi_compare.png'
|
|
198
|
+
_save_figure(fig, auto_output, dpi)
|
|
199
|
+
|
|
200
|
+
plt.close(fig)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
def detect(
|
|
205
|
+
file: str = typer.Argument(..., help='Path to XVG file'),
|
|
206
|
+
):
|
|
207
|
+
"""Detect and print XVG file type and metadata without plotting."""
|
|
208
|
+
if not os.path.exists(file):
|
|
209
|
+
console.print(f'[red]Error: File not found — {file}[/red]')
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
df, metadata = read_xvg(file)
|
|
213
|
+
_print_file_info(file, metadata)
|
|
214
|
+
console.print(f' Columns count: {metadata.n_columns}')
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def batch(
|
|
219
|
+
input_dir: str = typer.Argument('.', help='Input directory with XVG files'),
|
|
220
|
+
output_dir: str = typer.Option('.', '--output-dir', '-o', help='Output directory for plots'),
|
|
221
|
+
roll_avg: Optional[int] = typer.Option(None, '--roll-avg', '-r',
|
|
222
|
+
help='Rolling average window size (auto if not set)'),
|
|
223
|
+
dpi: int = typer.Option(300, '--dpi', '-d', help='DPI for saved images'),
|
|
224
|
+
ext: str = typer.Option('png', '--ext', '-e', help='Export format (png, pdf, svg, jpg)'),
|
|
225
|
+
stats: bool = typer.Option(True, '--stats/--no-stats', help='Generate statistics CSV'),
|
|
226
|
+
stats_file: str = typer.Option('xvg-stats.csv', '--stats-file', help='Statistics CSV filename'),
|
|
227
|
+
mean: bool = typer.Option(False, '--mean', '-m', help='Plot mean lines'),
|
|
228
|
+
std: bool = typer.Option(False, '--std', '-s', help='Plot mean +/- std lines'),
|
|
229
|
+
latex: Optional[bool] = typer.Option(None, '--latex/--no-latex',
|
|
230
|
+
help='Enable/disable LaTeX rendering'),
|
|
231
|
+
):
|
|
232
|
+
"""Batch process all XVG files in a directory."""
|
|
233
|
+
input_path = Path(input_dir)
|
|
234
|
+
if not input_path.exists():
|
|
235
|
+
console.print(f'[red]Error: Directory not found — {input_dir}[/red]')
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
_setup_latex(latex)
|
|
239
|
+
output_path = Path(output_dir)
|
|
240
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
|
|
242
|
+
xvg_files = sorted(input_path.glob('*.xvg'))
|
|
243
|
+
if not xvg_files:
|
|
244
|
+
console.print(f'[red]Error: No XVG files in {input_dir}[/red]')
|
|
245
|
+
raise typer.Exit(1)
|
|
246
|
+
|
|
247
|
+
console.print(f'Found {len(xvg_files)} XVG file(s)')
|
|
248
|
+
all_file_paths = []
|
|
249
|
+
results = []
|
|
250
|
+
|
|
251
|
+
for xvg_file in xvg_files:
|
|
252
|
+
console.print(f'\nProcessing: {xvg_file.name}')
|
|
253
|
+
df, metadata = read_xvg(str(xvg_file))
|
|
254
|
+
|
|
255
|
+
if df.empty:
|
|
256
|
+
console.print(f' [red]Error: Could not parse[/red]')
|
|
257
|
+
results.append((xvg_file.name, 'error', 'parse failed'))
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
if roll_avg is None:
|
|
261
|
+
ra = calculate_roll_avg(df, df.columns[0])
|
|
262
|
+
else:
|
|
263
|
+
ra = roll_avg
|
|
264
|
+
|
|
265
|
+
fig = create_plot(df, metadata, roll_avg=ra, show_mean=mean, show_std=std)
|
|
266
|
+
out_file = output_path / f'{xvg_file.stem}_plot.{ext}'
|
|
267
|
+
fig.savefig(str(out_file), dpi=dpi, bbox_inches='tight')
|
|
268
|
+
console.print(f' Saved: {out_file}')
|
|
269
|
+
plt.close(fig)
|
|
270
|
+
|
|
271
|
+
all_file_paths.append(str(xvg_file))
|
|
272
|
+
results.append((xvg_file.name, 'success', metadata.plot_type))
|
|
273
|
+
|
|
274
|
+
# Statistics
|
|
275
|
+
if stats and all_file_paths:
|
|
276
|
+
stats_path = str(output_path / stats_file)
|
|
277
|
+
stats_df = generate_stats_csv(all_file_paths, stats_path)
|
|
278
|
+
if not stats_df.empty:
|
|
279
|
+
console.print(f'\nStatistics saved to: {stats_path}')
|
|
280
|
+
table = format_stats_table(stats_df)
|
|
281
|
+
console.print(table)
|
|
282
|
+
|
|
283
|
+
# Summary
|
|
284
|
+
console.print('\n' + '=' * 50)
|
|
285
|
+
console.print('BATCH SUMMARY')
|
|
286
|
+
console.print('=' * 50)
|
|
287
|
+
for filename, status, info in results:
|
|
288
|
+
icon = '[green]OK[/green]' if status == 'success' else '[red]ERR[/red]'
|
|
289
|
+
console.print(f' {icon} {filename}: {info}')
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@app.command()
|
|
293
|
+
def stats(
|
|
294
|
+
files: List[str] = typer.Argument(..., help='XVG files to analyze'),
|
|
295
|
+
output: str = typer.Option('xvg-stats.csv', '--output', '-o', help='Output CSV path'),
|
|
296
|
+
):
|
|
297
|
+
"""Generate statistical summary CSV for XVG files."""
|
|
298
|
+
valid_files = [f for f in files if os.path.exists(f)]
|
|
299
|
+
if not valid_files:
|
|
300
|
+
console.print('[red]Error: No valid files[/red]')
|
|
301
|
+
raise typer.Exit(1)
|
|
302
|
+
|
|
303
|
+
stats_df = generate_stats_csv(valid_files, output)
|
|
304
|
+
if not stats_df.empty:
|
|
305
|
+
console.print(f'Saved to: {output}')
|
|
306
|
+
table = format_stats_table(stats_df)
|
|
307
|
+
console.print(table)
|
|
308
|
+
else:
|
|
309
|
+
console.print('[red]Error: No data to summarize[/red]')
|
|
310
|
+
raise typer.Exit(1)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == '__main__':
|
|
314
|
+
app()
|
gmxplot/comparison.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Two-file and multi-file comparison plotting with matplotlib."""
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from .escape_codes import resolve_axis_titles
|
|
8
|
+
from .plotting import calculate_roll_avg, _parse_y_units, _build_y_titles, clean_gromacs_label
|
|
9
|
+
from .config import (
|
|
10
|
+
FIGURE_SIZE_SINGLE, FIGURE_SIZE_MULTI_ROW_HEIGHT,
|
|
11
|
+
COMPARE_COLORS, MULTI_COLORS,
|
|
12
|
+
LINE_WIDTH_RA, ROLL_AVG_DEFAULT_COMPARE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compare_two(
|
|
17
|
+
df1: pd.DataFrame, meta1,
|
|
18
|
+
df2: pd.DataFrame, meta2,
|
|
19
|
+
label1: str = 'prot_1', label2: str = 'prot_2',
|
|
20
|
+
roll_avg: int = ROLL_AVG_DEFAULT_COMPARE,
|
|
21
|
+
) -> plt.Figure:
|
|
22
|
+
"""Create comparison plot for two XVG files, dispatching by type."""
|
|
23
|
+
plot_type = meta1.plot_type
|
|
24
|
+
|
|
25
|
+
if plot_type == 'energy':
|
|
26
|
+
return _compare_energy(df1, meta1, df2, meta2, label1, label2, roll_avg)
|
|
27
|
+
elif plot_type == 'pca':
|
|
28
|
+
return _compare_pca(df1, meta1, df2, meta2, label1, label2)
|
|
29
|
+
|
|
30
|
+
fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
|
|
31
|
+
color1, color2 = COMPARE_COLORS
|
|
32
|
+
|
|
33
|
+
if plot_type == 'gyration':
|
|
34
|
+
for col in ['Rg', 'RgX', 'RgY', 'RgZ']:
|
|
35
|
+
if col in df1.columns and col in df2.columns:
|
|
36
|
+
ra1 = df1[col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
37
|
+
ra2 = df2[col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
38
|
+
ax.plot(df1['Time'], ra1, color=color1, linewidth=LINE_WIDTH_RA,
|
|
39
|
+
label=f'{label1} {col}')
|
|
40
|
+
ax.plot(df2['Time'], ra2, color=color2, linewidth=LINE_WIDTH_RA,
|
|
41
|
+
label=f'{label2} {col}')
|
|
42
|
+
|
|
43
|
+
elif plot_type == 'rmsd':
|
|
44
|
+
if 'RMSD' in df1.columns and 'RMSD' in df2.columns:
|
|
45
|
+
ra1 = df1['RMSD'].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
46
|
+
ra2 = df2['RMSD'].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
47
|
+
ax.plot(df1['Time'], ra1, color=color1, linewidth=LINE_WIDTH_RA,
|
|
48
|
+
label=f'{label1} RMSD')
|
|
49
|
+
ax.plot(df2['Time'], ra2, color=color2, linewidth=LINE_WIDTH_RA,
|
|
50
|
+
label=f'{label2} RMSD')
|
|
51
|
+
|
|
52
|
+
elif plot_type == 'rmsf':
|
|
53
|
+
if 'RMSF' in df1.columns and 'RMSF' in df2.columns:
|
|
54
|
+
ax.plot(df1['Residue'], df1['RMSF'], color=color1, linewidth=LINE_WIDTH_RA,
|
|
55
|
+
label=f'{label1} RMSF')
|
|
56
|
+
ax.plot(df2['Residue'], df2['RMSF'], color=color2, linewidth=LINE_WIDTH_RA,
|
|
57
|
+
label=f'{label2} RMSF')
|
|
58
|
+
|
|
59
|
+
elif plot_type == 'sasa':
|
|
60
|
+
if 'Area' in df1.columns and 'Area' in df2.columns:
|
|
61
|
+
ra1 = df1['Area'].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
62
|
+
ra2 = df2['Area'].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
63
|
+
ax.plot(df1['Time'], ra1, color=color1, linewidth=LINE_WIDTH_RA,
|
|
64
|
+
label=f'{label1} SASA')
|
|
65
|
+
ax.plot(df2['Time'], ra2, color=color2, linewidth=LINE_WIDTH_RA,
|
|
66
|
+
label=f'{label2} SASA')
|
|
67
|
+
|
|
68
|
+
else:
|
|
69
|
+
# Generic XY comparison
|
|
70
|
+
x_col = df1.columns[0]
|
|
71
|
+
y_col = df1.columns[1] if len(df1.columns) > 1 else df1.columns[0]
|
|
72
|
+
if x_col in df1.columns and y_col in df1.columns:
|
|
73
|
+
ra1 = df1[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
74
|
+
ra2 = df2[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
75
|
+
ax.plot(df1[x_col], ra1, color=color1, linewidth=LINE_WIDTH_RA,
|
|
76
|
+
label=f'{label1}')
|
|
77
|
+
ax.plot(df2[x_col], ra2, color=color2, linewidth=LINE_WIDTH_RA,
|
|
78
|
+
label=f'{label2}')
|
|
79
|
+
|
|
80
|
+
x_title, y_title = resolve_axis_titles(meta1, 'X', 'Y')
|
|
81
|
+
ax.set_xlabel(x_title)
|
|
82
|
+
ax.set_ylabel(y_title)
|
|
83
|
+
ax.set_title(f'Comparison — {meta1.title}')
|
|
84
|
+
ax.legend()
|
|
85
|
+
fig.tight_layout()
|
|
86
|
+
return fig
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compare_multi(
|
|
90
|
+
data_list: list,
|
|
91
|
+
roll_avg: int = ROLL_AVG_DEFAULT_COMPARE,
|
|
92
|
+
) -> plt.Figure:
|
|
93
|
+
"""Create comparison plot for multiple XVG files of the same type.
|
|
94
|
+
|
|
95
|
+
data_list: list of (DataFrame, XVGMetadata, label) tuples.
|
|
96
|
+
"""
|
|
97
|
+
if not data_list:
|
|
98
|
+
raise ValueError('No data provided for comparison')
|
|
99
|
+
|
|
100
|
+
plot_type = data_list[0][1].plot_type
|
|
101
|
+
|
|
102
|
+
if plot_type == 'energy':
|
|
103
|
+
return _compare_multi_energy(data_list, roll_avg)
|
|
104
|
+
elif plot_type == 'pca':
|
|
105
|
+
return _compare_multi_pca(data_list)
|
|
106
|
+
|
|
107
|
+
fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
|
|
108
|
+
|
|
109
|
+
if plot_type == 'rmsf':
|
|
110
|
+
for idx, (df, meta, label) in enumerate(data_list):
|
|
111
|
+
if 'RMSF' in df.columns and 'Residue' in df.columns:
|
|
112
|
+
ax.plot(df['Residue'], df['RMSF'],
|
|
113
|
+
color=MULTI_COLORS[idx % len(MULTI_COLORS)],
|
|
114
|
+
linewidth=LINE_WIDTH_RA, label=label)
|
|
115
|
+
|
|
116
|
+
elif plot_type == 'sasa':
|
|
117
|
+
for idx, (df, meta, label) in enumerate(data_list):
|
|
118
|
+
if 'Area' in df.columns and 'Time' in df.columns:
|
|
119
|
+
ra = df['Area'].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
120
|
+
ax.plot(df['Time'], ra,
|
|
121
|
+
color=MULTI_COLORS[idx % len(MULTI_COLORS)],
|
|
122
|
+
linewidth=LINE_WIDTH_RA, label=label)
|
|
123
|
+
|
|
124
|
+
elif plot_type in ('rmsd', 'gyration'):
|
|
125
|
+
y_col = 'RMSD' if plot_type == 'rmsd' else 'Rg'
|
|
126
|
+
y_fallback = 'RMSD (nm)' if plot_type == 'rmsd' else 'Rg (nm)'
|
|
127
|
+
title = 'RMSD' if plot_type == 'rmsd' else 'Radius of Gyration'
|
|
128
|
+
for idx, (df, meta, label) in enumerate(data_list):
|
|
129
|
+
if y_col in df.columns and 'Time' in df.columns:
|
|
130
|
+
ra = df[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
131
|
+
ax.plot(df['Time'], ra,
|
|
132
|
+
color=MULTI_COLORS[idx % len(MULTI_COLORS)],
|
|
133
|
+
linewidth=LINE_WIDTH_RA, label=label)
|
|
134
|
+
|
|
135
|
+
else:
|
|
136
|
+
# Generic XY
|
|
137
|
+
for idx, (df, meta, label) in enumerate(data_list):
|
|
138
|
+
x_col = df.columns[0]
|
|
139
|
+
y_col = df.columns[1] if len(df.columns) > 1 else df.columns[0]
|
|
140
|
+
if x_col in df.columns and y_col in df.columns:
|
|
141
|
+
ra = df[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
142
|
+
ax.plot(df[x_col], ra,
|
|
143
|
+
color=MULTI_COLORS[idx % len(MULTI_COLORS)],
|
|
144
|
+
linewidth=LINE_WIDTH_RA, label=label)
|
|
145
|
+
|
|
146
|
+
x_title, y_title = resolve_axis_titles(data_list[0][1], 'X', 'Y')
|
|
147
|
+
ax.set_xlabel(x_title)
|
|
148
|
+
ax.set_ylabel(y_title)
|
|
149
|
+
ax.set_title(f'Comparison ({len(data_list)} files)')
|
|
150
|
+
ax.legend()
|
|
151
|
+
fig.tight_layout()
|
|
152
|
+
return fig
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _compare_energy(
|
|
156
|
+
df1, meta1, df2, meta2, label1, label2, roll_avg
|
|
157
|
+
) -> plt.Figure:
|
|
158
|
+
"""Energy comparison: multi-panel grid with both datasets per panel."""
|
|
159
|
+
y_cols = [c for c in df1.columns if c != 'Time' and c in df2.columns]
|
|
160
|
+
n_y = len(y_cols)
|
|
161
|
+
if n_y == 0:
|
|
162
|
+
fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
|
|
163
|
+
return fig
|
|
164
|
+
|
|
165
|
+
n_cols = min(2, n_y) if n_y > 1 else 1
|
|
166
|
+
n_rows = max(1, (n_y + n_cols - 1) // n_cols)
|
|
167
|
+
|
|
168
|
+
fig, axes = plt.subplots(n_rows, n_cols,
|
|
169
|
+
figsize=(12, n_rows * FIGURE_SIZE_MULTI_ROW_HEIGHT),
|
|
170
|
+
sharex=True, squeeze=False)
|
|
171
|
+
axes_flat = axes.flatten()
|
|
172
|
+
color1, color2 = COMPARE_COLORS
|
|
173
|
+
|
|
174
|
+
units = _parse_y_units(meta1.y_label)
|
|
175
|
+
y_titles = _build_y_titles(y_cols, units)
|
|
176
|
+
|
|
177
|
+
for idx, y_col in enumerate(y_cols):
|
|
178
|
+
ax = axes_flat[idx]
|
|
179
|
+
ra1 = df1[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
180
|
+
ra2 = df2[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
181
|
+
ax.plot(df1['Time'], ra1, color=color1, linewidth=LINE_WIDTH_RA, label=label1)
|
|
182
|
+
ax.plot(df2['Time'], ra2, color=color2, linewidth=LINE_WIDTH_RA, label=label2)
|
|
183
|
+
ax.set_ylabel(y_titles.get(y_col, y_col))
|
|
184
|
+
ax.set_title(y_col)
|
|
185
|
+
|
|
186
|
+
for idx in range(n_y, len(axes_flat)):
|
|
187
|
+
axes_flat[idx].set_visible(False)
|
|
188
|
+
|
|
189
|
+
x_title, _ = resolve_axis_titles(meta1, 'Time (ps)', '')
|
|
190
|
+
for ax_row in axes[-1]:
|
|
191
|
+
if ax_row.get_visible():
|
|
192
|
+
ax_row.set_xlabel(x_title)
|
|
193
|
+
|
|
194
|
+
fig.suptitle(f'Energy Comparison — {label1} vs {label2}', fontsize=14)
|
|
195
|
+
fig.tight_layout()
|
|
196
|
+
return fig
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _compare_pca(df1, meta1, df2, meta2, label1, label2) -> plt.Figure:
|
|
200
|
+
"""PCA comparison: side-by-side hexbin density plots."""
|
|
201
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
|
|
202
|
+
|
|
203
|
+
if 'PC1' in df1.columns and 'PC2' in df1.columns:
|
|
204
|
+
hb1 = ax1.hexbin(df1['PC1'], df1['PC2'], cmap='viridis', gridsize=30, mincnt=1)
|
|
205
|
+
fig.colorbar(hb1, ax=ax1, label='Density')
|
|
206
|
+
ax1.set_title(label1)
|
|
207
|
+
|
|
208
|
+
if 'PC1' in df2.columns and 'PC2' in df2.columns:
|
|
209
|
+
hb2 = ax2.hexbin(df2['PC1'], df2['PC2'], cmap='viridis', gridsize=30, mincnt=1)
|
|
210
|
+
fig.colorbar(hb2, ax=ax2, label='Density')
|
|
211
|
+
ax2.set_title(label2)
|
|
212
|
+
|
|
213
|
+
x_title, y_title = resolve_axis_titles(meta1, 'PC1', 'PC2')
|
|
214
|
+
ax1.set_xlabel(x_title)
|
|
215
|
+
ax1.set_ylabel(y_title)
|
|
216
|
+
ax2.set_xlabel(x_title)
|
|
217
|
+
ax2.set_ylabel(y_title)
|
|
218
|
+
|
|
219
|
+
fig.suptitle(f'PCA Comparison — {label1} vs {label2}', fontsize=14)
|
|
220
|
+
fig.tight_layout()
|
|
221
|
+
return fig
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _compare_multi_energy(data_list: list, roll_avg: int) -> plt.Figure:
|
|
225
|
+
"""Multi-file energy comparison: multi-panel grid with all files per panel."""
|
|
226
|
+
y_cols = [c for c in data_list[0][0].columns
|
|
227
|
+
if c != 'Time' and all(c in d[0].columns for d in data_list)]
|
|
228
|
+
n_y = len(y_cols)
|
|
229
|
+
if n_y == 0:
|
|
230
|
+
fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
|
|
231
|
+
return fig
|
|
232
|
+
|
|
233
|
+
n_cols = min(2, n_y) if n_y > 1 else 1
|
|
234
|
+
n_rows = max(1, (n_y + n_cols - 1) // n_cols)
|
|
235
|
+
|
|
236
|
+
fig, axes = plt.subplots(n_rows, n_cols,
|
|
237
|
+
figsize=(12, n_rows * FIGURE_SIZE_MULTI_ROW_HEIGHT),
|
|
238
|
+
sharex=True, squeeze=False)
|
|
239
|
+
axes_flat = axes.flatten()
|
|
240
|
+
|
|
241
|
+
units = _parse_y_units(data_list[0][1].y_label)
|
|
242
|
+
y_titles = _build_y_titles(y_cols, units)
|
|
243
|
+
|
|
244
|
+
for idx, y_col in enumerate(y_cols):
|
|
245
|
+
ax = axes_flat[idx]
|
|
246
|
+
for file_idx, (df, meta, label) in enumerate(data_list):
|
|
247
|
+
if y_col in df.columns:
|
|
248
|
+
ra = df[y_col].rolling(window=roll_avg, min_periods=1, center=True).mean()
|
|
249
|
+
ax.plot(df['Time'], ra,
|
|
250
|
+
color=MULTI_COLORS[file_idx % len(MULTI_COLORS)],
|
|
251
|
+
linewidth=LINE_WIDTH_RA, label=label)
|
|
252
|
+
ax.set_ylabel(y_titles.get(y_col, y_col))
|
|
253
|
+
ax.set_title(y_col)
|
|
254
|
+
|
|
255
|
+
for idx in range(n_y, len(axes_flat)):
|
|
256
|
+
axes_flat[idx].set_visible(False)
|
|
257
|
+
|
|
258
|
+
x_title, _ = resolve_axis_titles(data_list[0][1], 'Time (ps)', '')
|
|
259
|
+
for ax_row in axes[-1]:
|
|
260
|
+
if ax_row.get_visible():
|
|
261
|
+
ax_row.set_xlabel(x_title)
|
|
262
|
+
|
|
263
|
+
fig.suptitle(f'Energy Comparison ({len(data_list)} files)', fontsize=14)
|
|
264
|
+
fig.tight_layout()
|
|
265
|
+
return fig
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _compare_multi_pca(data_list: list) -> plt.Figure:
|
|
269
|
+
"""Multi-file PCA comparison: multi-panel hexbin layout."""
|
|
270
|
+
n_files = len(data_list)
|
|
271
|
+
n_cols = min(3, n_files)
|
|
272
|
+
n_rows = max(1, (n_files + n_cols - 1) // n_cols)
|
|
273
|
+
|
|
274
|
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=(14, 6 * n_rows), squeeze=False)
|
|
275
|
+
axes_flat = axes.flatten()
|
|
276
|
+
|
|
277
|
+
x_title, y_title = resolve_axis_titles(data_list[0][1], 'PC1', 'PC2')
|
|
278
|
+
|
|
279
|
+
for idx, (df, meta, label) in enumerate(data_list):
|
|
280
|
+
ax = axes_flat[idx]
|
|
281
|
+
if 'PC1' in df.columns and 'PC2' in df.columns:
|
|
282
|
+
hb = ax.hexbin(df['PC1'], df['PC2'], cmap='viridis', gridsize=30, mincnt=1)
|
|
283
|
+
fig.colorbar(hb, ax=ax, label='Density')
|
|
284
|
+
ax.set_xlabel(x_title)
|
|
285
|
+
ax.set_ylabel(y_title)
|
|
286
|
+
ax.set_title(label)
|
|
287
|
+
|
|
288
|
+
for idx in range(n_files, len(axes_flat)):
|
|
289
|
+
axes_flat[idx].set_visible(False)
|
|
290
|
+
|
|
291
|
+
fig.suptitle(f'PCA Comparison ({n_files} files)', fontsize=14)
|
|
292
|
+
fig.tight_layout()
|
|
293
|
+
return fig
|
gmxplot/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Default configuration constants for gmxplot."""
|
|
2
|
+
|
|
3
|
+
# Figure defaults
|
|
4
|
+
FIGURE_SIZE_SINGLE = (10, 6)
|
|
5
|
+
FIGURE_SIZE_MULTI_ROW_HEIGHT = 4 # inches per row for multi-panel
|
|
6
|
+
DPI_DEFAULT = 300
|
|
7
|
+
|
|
8
|
+
# Line styling
|
|
9
|
+
LINE_WIDTH_RAW = 1.0
|
|
10
|
+
LINE_WIDTH_RA = 1.5
|
|
11
|
+
RAW_ALPHA = 0.5
|
|
12
|
+
|
|
13
|
+
# Colors
|
|
14
|
+
RA_COLOR = '#1f77b4'
|
|
15
|
+
RAW_COLOR = '#aec6cf'
|
|
16
|
+
COMPARE_COLORS = ('#1f77b4', '#d62728')
|
|
17
|
+
|
|
18
|
+
# Multi-file color palette (tab10)
|
|
19
|
+
MULTI_COLORS = [
|
|
20
|
+
'#1f77b4', '#d62728', '#2ca02c', '#9467bd',
|
|
21
|
+
'#ff7f0e', '#8c564b', '#e377c2', '#7f7f7f',
|
|
22
|
+
'#bcbd22', '#17becf',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Rolling average
|
|
26
|
+
ROLL_AVG_DEFAULT_PLOT = None # None = auto-calculate
|
|
27
|
+
ROLL_AVG_DEFAULT_COMPARE = 50
|
|
28
|
+
ROLL_AVG_MIN = 3
|
|
29
|
+
ROLL_AVG_MAX = 100
|
|
30
|
+
ROLL_AVG_FRACTION = 0.05 # 5% of data range
|
|
31
|
+
|
|
32
|
+
# Plot type names
|
|
33
|
+
PLOT_TYPES = ['energy', 'gyration', 'rmsd', 'rmsf', 'sasa', 'pca', 'xy', 'unknown']
|
|
34
|
+
|
|
35
|
+
# Energy subplot layout
|
|
36
|
+
ENERGY_MAX_COLS = 2
|
|
37
|
+
ENERGY_PER_ROW = 2
|