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 ADDED
@@ -0,0 +1,3 @@
1
+ """gmxplot - GROMACS XVG file visualization tool."""
2
+
3
+ __version__ = "0.1.0"
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