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.
@@ -0,0 +1,54 @@
1
+ """GROMACS xmgrace escape code conversion for matplotlib."""
2
+
3
+ import re
4
+ from typing import Tuple
5
+
6
+ _GROMACS_FONT_RE = re.compile(r'\\f\{[^}]*\}')
7
+ _GROMACS_SUP_RE = re.compile(r'\\S(.*?)\\N')
8
+ _GROMACS_SUB_RE = re.compile(r'\\s(.*?)\\N')
9
+
10
+
11
+ def clean_gromacs_label(text: str) -> str:
12
+ """Convert GROMACS xmgrace escape codes into matplotlib-compatible notation.
13
+
14
+ - \\f{...} font switches -> removed
15
+ - \\S...\\N superscript -> $^{...}$ (matplotlib mathtext/LaTeX)
16
+ - \\s...\\N subscript -> $_{...}$ (matplotlib mathtext/LaTeX)
17
+ """
18
+ if not text:
19
+ return text
20
+ cleaned = _GROMACS_FONT_RE.sub('', text)
21
+ cleaned = _GROMACS_SUP_RE.sub(r'$^{\1}$', cleaned)
22
+ cleaned = _GROMACS_SUB_RE.sub(r'$_{\1}$', cleaned)
23
+ return cleaned
24
+
25
+
26
+ # Per-type fallback axis titles
27
+ TYPE_FALLBACKS: dict[str, tuple[str, str]] = {
28
+ 'energy': ('Time (ps)', ''),
29
+ 'gyration': ('Time (ps)', 'Rg (nm)'),
30
+ 'rmsd': ('Time (ns)', 'RMSD (nm)'),
31
+ 'rmsf': ('Residue', 'RMSF (nm)'),
32
+ 'sasa': ('Time (ns)', 'Area (nm$^{2}$)'),
33
+ 'pca': ('PC1', 'PC2'),
34
+ 'xy': ('X', 'Y'),
35
+ }
36
+
37
+
38
+ def resolve_axis_titles(
39
+ metadata,
40
+ fallback_x: str = '',
41
+ fallback_y: str = '',
42
+ ) -> Tuple[str, str]:
43
+ """Return (x_title, y_title) preferring metadata, cleaned of GROMACS codes.
44
+
45
+ Uses TYPE_FALLBACKS when no fallback is explicitly provided.
46
+ """
47
+ if not fallback_x and metadata.plot_type in TYPE_FALLBACKS:
48
+ fallback_x = TYPE_FALLBACKS[metadata.plot_type][0]
49
+ if not fallback_y and metadata.plot_type in TYPE_FALLBACKS:
50
+ fallback_y = TYPE_FALLBACKS[metadata.plot_type][1]
51
+
52
+ x_title = clean_gromacs_label(metadata.x_label) if metadata.x_label else fallback_x
53
+ y_title = clean_gromacs_label(metadata.y_label) if metadata.y_label else fallback_y
54
+ return x_title, y_title
gmxplot/parser.py ADDED
@@ -0,0 +1,247 @@
1
+ """XVG file parsing and plot type classification."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Optional, Tuple
6
+
7
+ import pandas as pd
8
+
9
+
10
+ @dataclass
11
+ class XVGMetadata:
12
+ """Metadata extracted from an XVG file header."""
13
+ title: str = ''
14
+ subtitle: str = ''
15
+ x_label: str = ''
16
+ y_label: str = ''
17
+ plot_type: str = '' # energy, gyration, rmsd, rmsf, sasa, pca, xy, unknown
18
+ x_unit: str = ''
19
+ y_unit: str = ''
20
+ legend_labels: List[str] = field(default_factory=list)
21
+ n_columns: int = 0
22
+ file_path: str = ''
23
+ command: str = '' # Original GROMACS command (e.g., rms, gyrate)
24
+ gmx_version: str = ''
25
+
26
+
27
+ def read_xvg(file_path: str) -> Tuple[pd.DataFrame, XVGMetadata]:
28
+ """Read an XVG file and return (DataFrame, XVGMetadata)."""
29
+ metadata = XVGMetadata(file_path=file_path)
30
+ data_lines: List[str] = []
31
+ legend_labels: dict = {}
32
+ command = ''
33
+ gmx_version = ''
34
+
35
+ with open(file_path, 'r') as f:
36
+ for line in f:
37
+ line = line.strip()
38
+ if not line:
39
+ continue
40
+
41
+ # Comment lines — extract GROMACS version info
42
+ if line.startswith('#'):
43
+ if 'GROMACS' in line:
44
+ m = re.search(r'GROMACS(?: - gmx (\w+))?,?\s*(\d+)', line)
45
+ if m:
46
+ command = m.group(1) or command
47
+ gmx_version = m.group(2)
48
+ continue
49
+
50
+ # @ directives
51
+ if line.startswith('@'):
52
+ directive = line[1:].strip()
53
+
54
+ # Title
55
+ if directive.startswith('title'):
56
+ m = re.search(r'"([^"]*)"', line)
57
+ if m:
58
+ metadata.title = m.group(1)
59
+
60
+ # Subtitle
61
+ elif directive.startswith('subtitle'):
62
+ m = re.search(r'"([^"]*)"', line)
63
+ if m:
64
+ metadata.subtitle = m.group(1)
65
+
66
+ # X axis label (handle variable spacing)
67
+ elif re.match(r'^xaxis\s+label\s*', directive):
68
+ m = re.search(r'"([^"]*)"', line)
69
+ if m:
70
+ metadata.x_label = m.group(1)
71
+
72
+ # Y axis label
73
+ elif re.match(r'^yaxis\s+label\s*', directive):
74
+ m = re.search(r'"([^"]*)"', line)
75
+ if m:
76
+ metadata.y_label = m.group(1)
77
+
78
+ # Legend labels (s0, s1, etc.)
79
+ elif re.match(r's\d+\s+legend', directive):
80
+ m = re.search(r's(\d+)\s+legend\s+"([^"]+)"', line)
81
+ if m:
82
+ legend_labels[int(m.group(1))] = m.group(2)
83
+
84
+ # Command line info
85
+ elif 'command' in directive.lower() and 'gmx' in line:
86
+ m = re.search(r'gmx (\w+)', line)
87
+ if m:
88
+ command = m.group(1)
89
+
90
+ continue
91
+
92
+ # Numeric data lines — skip &, @, #
93
+ if line.startswith(('&', '#', '@')):
94
+ continue
95
+
96
+ parts = line.split()
97
+ if len(parts) >= 2:
98
+ try:
99
+ float(parts[0])
100
+ float(parts[1])
101
+ data_lines.append(line)
102
+ except ValueError:
103
+ continue
104
+
105
+ metadata.command = command
106
+ metadata.gmx_version = gmx_version
107
+ metadata.legend_labels = [
108
+ legend_labels.get(i, f'Col{i}') for i in range(len(legend_labels))
109
+ ]
110
+
111
+ # Parse numeric data
112
+ if data_lines:
113
+ metadata = _parse_data_to_dataframe(data_lines, metadata)
114
+
115
+ # Classify plot type
116
+ metadata.plot_type = _classify_plot_type(metadata)
117
+
118
+ if not hasattr(metadata, 'df') or metadata.df is None:
119
+ metadata.df = pd.DataFrame()
120
+
121
+ return metadata.df, metadata
122
+
123
+
124
+ def _parse_data_to_dataframe(
125
+ data_lines: List[str], metadata: XVGMetadata
126
+ ) -> XVGMetadata:
127
+ """Parse data lines into DataFrame with intelligent column naming."""
128
+ parsed = []
129
+ for line in data_lines:
130
+ parts = re.split(r'[,\s\t]+', line)
131
+ parsed.append([p for p in parts if p])
132
+
133
+ if not parsed:
134
+ metadata.df = pd.DataFrame()
135
+ return metadata
136
+
137
+ num_cols = len(parsed[0])
138
+ metadata.n_columns = num_cols
139
+ title_lower = metadata.title.lower()
140
+ x_label_lower = metadata.x_label.lower()
141
+
142
+ if num_cols >= 2:
143
+ columns: List[str] = ['X'] + [f'Y{i}' for i in range(1, num_cols)]
144
+
145
+ # --- X column naming ---
146
+ if 'time' in x_label_lower or 'time' in title_lower:
147
+ columns[0] = 'Time'
148
+ elif 'step' in x_label_lower:
149
+ columns[0] = 'Step'
150
+ elif 'residue' in x_label_lower or 'residue' in title_lower:
151
+ columns[0] = 'Residue'
152
+ elif 'atom' in x_label_lower:
153
+ columns[0] = 'Atom'
154
+ elif 'eigen' in x_label_lower:
155
+ columns[0] = 'Eigenvalue'
156
+ elif 'projection' in x_label_lower:
157
+ columns[0] = 'PC1'
158
+ columns[1] = 'PC2'
159
+
160
+ # --- Y column naming ---
161
+ if 'potential' in title_lower or 'energ' in title_lower:
162
+ if metadata.legend_labels and len(metadata.legend_labels) >= num_cols - 1:
163
+ columns = ['Time'] + metadata.legend_labels[:num_cols - 1]
164
+ else:
165
+ defaults = ['Potential', 'Temperature', 'Pressure', 'Density']
166
+ columns = ['Time'] + defaults[:num_cols - 1]
167
+
168
+ elif 'gyration' in title_lower or re.search(r'\brg\b', title_lower):
169
+ if num_cols >= 4:
170
+ columns = ['Time', 'Rg', 'RgX', 'RgY', 'RgZ']
171
+ else:
172
+ columns = ['Time', 'Rg']
173
+
174
+ elif ('area' in metadata.y_label.lower()
175
+ or 'sasa' in title_lower
176
+ or 'solvent' in title_lower):
177
+ if 'time' in x_label_lower:
178
+ columns = ['Time', 'Area', 'StdDev'][:num_cols]
179
+ else:
180
+ columns = ['Residue', 'Area', 'StdDev'][:num_cols]
181
+
182
+ elif 'rmsd' in title_lower or 'rmsd' in metadata.y_label.lower():
183
+ columns = ['Time', 'RMSD']
184
+
185
+ elif 'rmsf' in title_lower or 'fluctuation' in title_lower:
186
+ columns = ['Residue', 'RMSF']
187
+
188
+ elif 'pca' in title_lower or 'projection' in title_lower:
189
+ columns = ['PC1', 'PC2']
190
+
191
+ # Build DataFrame
192
+ df = pd.DataFrame(parsed, columns=columns[:num_cols])
193
+ for col in df.columns:
194
+ df[col] = pd.to_numeric(df[col], errors='coerce')
195
+
196
+ metadata.df = df
197
+ return metadata
198
+
199
+
200
+ def _classify_plot_type(metadata: XVGMetadata) -> str:
201
+ """Classify plot type from title (primary) and axis labels / column count (fallback)."""
202
+ title = metadata.title.lower()
203
+
204
+ # ── Title-based detection ────────────────────────────────────────
205
+ title_rules: List[Tuple[str, str]] = [
206
+ ('2d projection', 'pca'),
207
+ ('projection of trajectory', 'pca'),
208
+ ('energies', 'energy'),
209
+ ('energy', 'energy'),
210
+ ('solvent accessible surface', 'sasa'),
211
+ ('area per residue', 'sasa'),
212
+ ('sasa', 'sasa'),
213
+ ('rms fluctuation', 'rmsf'),
214
+ ('rmsf', 'rmsf'),
215
+ ('rmsd', 'rmsd'),
216
+ ('radius of gyration', 'gyration'),
217
+ ('gyration', 'gyration'),
218
+ ]
219
+
220
+ for pattern, plot_type in title_rules:
221
+ if pattern in title:
222
+ return plot_type
223
+
224
+ # ── Fallback heuristics ─────────────────────────────────────────
225
+ x_label = metadata.x_label.lower()
226
+ y_label = metadata.y_label.lower()
227
+ n_cols = metadata.n_columns
228
+
229
+ if n_cols >= 4 and 'time' in x_label:
230
+ if any(kw in y_label for kw in ['kj/mol', '(k)', '(bar)', 'kg/m']):
231
+ return 'energy'
232
+ if 'eigenvector' in x_label and 'eigenvector' in y_label:
233
+ return 'pca'
234
+ if 'fluctuation' in y_label or 'rmsf' in y_label:
235
+ return 'rmsf'
236
+ if 'residue' in x_label and 'rmsf' in y_label:
237
+ return 'rmsf'
238
+ if 'rmsd' in y_label:
239
+ return 'rmsd'
240
+ if 'radius of gyration' in y_label or re.search(r'\brg\b', y_label):
241
+ return 'gyration'
242
+ if 'area' in y_label and 'nm' in y_label:
243
+ return 'sasa'
244
+ if n_cols >= 2:
245
+ return 'xy'
246
+
247
+ return 'unknown'
gmxplot/plotting.py ADDED
@@ -0,0 +1,268 @@
1
+ """Matplotlib plotting functions for all XVG plot types."""
2
+
3
+ import re
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
6
+ import pandas as pd
7
+ import seaborn as sns
8
+
9
+ from .escape_codes import clean_gromacs_label, resolve_axis_titles
10
+ from .config import (
11
+ FIGURE_SIZE_SINGLE, FIGURE_SIZE_MULTI_ROW_HEIGHT, DPI_DEFAULT,
12
+ LINE_WIDTH_RAW, LINE_WIDTH_RA, RAW_ALPHA, RA_COLOR, RAW_COLOR,
13
+ ROLL_AVG_MIN, ROLL_AVG_MAX, ROLL_AVG_FRACTION,
14
+ )
15
+
16
+ # Apply seaborn style defaults
17
+ sns.set_style('whitegrid')
18
+ sns.set_context('paper', font_scale=1.2)
19
+
20
+
21
+ def calculate_roll_avg(df: pd.DataFrame, x_col: str = 'Time') -> int:
22
+ """Calculate rolling average window: 5% of data range, min 3, max 100."""
23
+ if x_col not in df.columns or len(df) < 10:
24
+ return 10
25
+ x_range = df[x_col].max() - df[x_col].min()
26
+ window = max(ROLL_AVG_MIN, min(ROLL_AVG_MAX, int(x_range * ROLL_AVG_FRACTION)))
27
+ return max(3, window)
28
+
29
+
30
+ def _add_ra_trace(ax, x, y, roll_avg, raw_color=RAW_COLOR, ra_color=RA_COLOR):
31
+ """Add raw data line + rolling average overlay to axes."""
32
+ y_ra = y.rolling(window=roll_avg, min_periods=1, center=True).mean()
33
+ ax.plot(x, y, color=raw_color, alpha=RAW_ALPHA, linewidth=LINE_WIDTH_RAW, label='Raw')
34
+ ax.plot(x, y_ra, color=ra_color, linewidth=LINE_WIDTH_RA, label=f'RA (n={roll_avg})')
35
+
36
+
37
+ def _add_mean_std_lines(ax, df, y_col, show_mean=False, show_std=False):
38
+ """Optionally draw mean and mean +/- std horizontal lines."""
39
+ if y_col not in df.columns:
40
+ return
41
+ y_data = df[y_col]
42
+ mean = y_data.mean()
43
+ std = y_data.std()
44
+ color = RA_COLOR
45
+ if show_mean:
46
+ ax.axhline(y=mean, color=color, linestyle='--', linewidth=1, alpha=0.8)
47
+ if show_std:
48
+ ax.axhline(y=mean + std, color=color, linestyle=':', linewidth=0.5, alpha=0.6)
49
+ ax.axhline(y=mean - std, color=color, linestyle=':', linewidth=0.5, alpha=0.6)
50
+
51
+
52
+ def plot_energy(df: pd.DataFrame, metadata, roll_avg=None,
53
+ show_mean=False, show_std=False) -> plt.Figure:
54
+ """Plot energy components with rolling average in dynamic subplot grid."""
55
+ if roll_avg is None:
56
+ roll_avg = calculate_roll_avg(df, 'Time')
57
+
58
+ y_cols = [c for c in df.columns if c != 'Time']
59
+ n_y = len(y_cols)
60
+ if n_y == 0:
61
+ return plot_xy(df, metadata, roll_avg, show_mean, show_std)
62
+
63
+ # Grid layout
64
+ n_cols = min(2, n_y) if n_y > 1 else 1
65
+ n_rows = max(1, (n_y + n_cols - 1) // n_cols)
66
+
67
+ fig, axes = plt.subplots(n_rows, n_cols, figsize=(12, n_rows * FIGURE_SIZE_MULTI_ROW_HEIGHT),
68
+ sharex=True, squeeze=False)
69
+ axes_flat = axes.flatten()
70
+
71
+ # Parse per-column units from y_label
72
+ units = _parse_y_units(metadata.y_label)
73
+ y_titles = _build_y_titles(y_cols, units)
74
+
75
+ for idx, y_col in enumerate(y_cols):
76
+ ax = axes_flat[idx]
77
+ _add_ra_trace(ax, df['Time'], df[y_col], roll_avg)
78
+ _add_mean_std_lines(ax, df, y_col, show_mean, show_std)
79
+ ax.set_ylabel(y_titles.get(y_col, y_col))
80
+ ax.set_title(y_col)
81
+
82
+ # Hide unused axes
83
+ for idx in range(n_y, len(axes_flat)):
84
+ axes_flat[idx].set_visible(False)
85
+
86
+ x_title, _ = resolve_axis_titles(metadata, 'Time (ps)', '')
87
+ for ax_row in axes[-1]:
88
+ if ax_row.get_visible():
89
+ ax_row.set_xlabel(x_title)
90
+
91
+ fig.suptitle('Energy Components', fontsize=14)
92
+ fig.tight_layout()
93
+ return fig
94
+
95
+
96
+ def plot_gyration(df: pd.DataFrame, metadata, roll_avg=None,
97
+ show_mean=False, show_std=False) -> plt.Figure:
98
+ """Plot radius of gyration (Rg only)."""
99
+ if roll_avg is None:
100
+ roll_avg = calculate_roll_avg(df, 'Time')
101
+
102
+ fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
103
+
104
+ if 'Rg' in df.columns:
105
+ _add_ra_trace(ax, df['Time'], df['Rg'], roll_avg)
106
+ _add_mean_std_lines(ax, df, 'Rg', show_mean, show_std)
107
+
108
+ x_title, y_title = resolve_axis_titles(metadata, 'Time (ps)', 'Rg (nm)')
109
+ ax.set_xlabel(x_title)
110
+ ax.set_ylabel(y_title)
111
+ ax.set_title(f'Radius of Gyration — {metadata.title}')
112
+ ax.legend()
113
+ fig.tight_layout()
114
+ return fig
115
+
116
+
117
+ def plot_rmsd(df: pd.DataFrame, metadata, roll_avg=None,
118
+ show_mean=False, show_std=False) -> plt.Figure:
119
+ """Plot RMSD with rolling average."""
120
+ if roll_avg is None:
121
+ roll_avg = calculate_roll_avg(df, 'Time')
122
+
123
+ fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
124
+
125
+ if 'RMSD' in df.columns:
126
+ _add_ra_trace(ax, df['Time'], df['RMSD'], roll_avg)
127
+ _add_mean_std_lines(ax, df, 'RMSD', show_mean, show_std)
128
+
129
+ x_title, y_title = resolve_axis_titles(metadata, 'Time (ns)', 'RMSD (nm)')
130
+ ax.set_xlabel(x_title)
131
+ ax.set_ylabel(y_title)
132
+ ax.set_title(f'RMSD — {metadata.title}')
133
+ ax.legend()
134
+ fig.tight_layout()
135
+ return fig
136
+
137
+
138
+ def plot_rmsf(df: pd.DataFrame, metadata, roll_avg=None,
139
+ show_mean=False, show_std=False) -> plt.Figure:
140
+ """Plot RMSF per residue (no rolling average)."""
141
+ fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
142
+
143
+ if 'RMSF' in df.columns:
144
+ ax.plot(df['Residue'], df['RMSF'], color=RA_COLOR, linewidth=LINE_WIDTH_RA)
145
+ _add_mean_std_lines(ax, df, 'RMSF', show_mean, show_std)
146
+
147
+ x_title, y_title = resolve_axis_titles(metadata, 'Residue', 'RMSF (nm)')
148
+ ax.set_xlabel(x_title)
149
+ ax.set_ylabel(y_title)
150
+ ax.set_title(f'RMSF — {metadata.title}')
151
+ fig.tight_layout()
152
+ return fig
153
+
154
+
155
+ def plot_sasa(df: pd.DataFrame, metadata, roll_avg=None,
156
+ show_mean=False, show_std=False) -> plt.Figure:
157
+ """Plot SASA — time-series with RA, or per-residue without RA."""
158
+ fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
159
+
160
+ if 'Time' in df.columns:
161
+ x_col = 'Time'
162
+ fallback_x = 'Time (ns)'
163
+ elif 'Residue' in df.columns:
164
+ x_col = 'Residue'
165
+ fallback_x = 'Residue'
166
+ else:
167
+ x_col = df.columns[0]
168
+ fallback_x = x_col
169
+
170
+ x_title, y_title = resolve_axis_titles(metadata, fallback_x, 'Area (nm$^{2}$)')
171
+
172
+ if 'Area' in df.columns:
173
+ if x_col == 'Residue':
174
+ ax.plot(df[x_col], df['Area'], color=RA_COLOR, linewidth=LINE_WIDTH_RA)
175
+ else:
176
+ if roll_avg is None:
177
+ roll_avg = calculate_roll_avg(df, x_col)
178
+ _add_ra_trace(ax, df[x_col], df['Area'], roll_avg)
179
+ _add_mean_std_lines(ax, df, 'Area', show_mean, show_std)
180
+
181
+ ax.set_xlabel(x_title)
182
+ ax.set_ylabel(y_title)
183
+ ax.set_title(f'SASA — {metadata.title}')
184
+ if x_col == 'Time':
185
+ ax.legend()
186
+ fig.tight_layout()
187
+ return fig
188
+
189
+
190
+ def plot_pca(df: pd.DataFrame, metadata, roll_avg=None,
191
+ show_mean=False, show_std=False) -> plt.Figure:
192
+ """Plot PCA projection as 2D hexbin density with viridis colormap."""
193
+ fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
194
+
195
+ if 'PC1' in df.columns and 'PC2' in df.columns:
196
+ hb = ax.hexbin(df['PC1'], df['PC2'], cmap='viridis', gridsize=30, mincnt=1)
197
+ fig.colorbar(hb, ax=ax, label='Density')
198
+
199
+ x_title, y_title = resolve_axis_titles(metadata, 'PC1', 'PC2')
200
+ ax.set_xlabel(x_title)
201
+ ax.set_ylabel(y_title)
202
+ ax.set_title(f'PCA Projection — {metadata.title}')
203
+ fig.tight_layout()
204
+ return fig
205
+
206
+
207
+ def plot_xy(df: pd.DataFrame, metadata, roll_avg=None,
208
+ show_mean=False, show_std=False) -> plt.Figure:
209
+ """Generic XY plot for unrecognized types."""
210
+ x_col = df.columns[0]
211
+ y_col = df.columns[1] if len(df.columns) > 1 else df.columns[0]
212
+
213
+ if roll_avg is None:
214
+ roll_avg = calculate_roll_avg(df, x_col)
215
+
216
+ fig, ax = plt.subplots(figsize=FIGURE_SIZE_SINGLE)
217
+
218
+ # Multiple Y columns: each as separate line
219
+ y_cols = list(df.columns[1:])
220
+ if len(y_cols) <= 1:
221
+ _add_ra_trace(ax, df[x_col], df[y_col], roll_avg)
222
+ _add_mean_std_lines(ax, df, y_col, show_mean, show_std)
223
+ else:
224
+ for yc in y_cols:
225
+ y_ra = df[yc].rolling(window=roll_avg, min_periods=1, center=True).mean()
226
+ ax.plot(df[x_col], y_ra, linewidth=LINE_WIDTH_RA, label=yc)
227
+ _add_mean_std_lines(ax, df, yc, show_mean, show_std)
228
+
229
+ x_title, y_title = resolve_axis_titles(metadata, x_col, y_col)
230
+ ax.set_xlabel(x_title)
231
+ ax.set_ylabel(y_title)
232
+ ax.set_title(f'Plot — {metadata.title}')
233
+ ax.legend()
234
+ fig.tight_layout()
235
+ return fig
236
+
237
+
238
+ def create_plot(df: pd.DataFrame, metadata, roll_avg=None,
239
+ show_mean=False, show_std=False) -> plt.Figure:
240
+ """Dispatch to type-specific plot function."""
241
+ plot_type = metadata.plot_type
242
+ func_map = {
243
+ 'energy': plot_energy,
244
+ 'gyration': plot_gyration,
245
+ 'rmsd': plot_rmsd,
246
+ 'rmsf': plot_rmsf,
247
+ 'sasa': plot_sasa,
248
+ 'pca': plot_pca,
249
+ 'xy': plot_xy,
250
+ 'unknown': plot_xy,
251
+ }
252
+ func = func_map.get(plot_type, plot_xy)
253
+ return func(df, metadata, roll_avg, show_mean, show_std)
254
+
255
+
256
+ def _parse_y_units(y_label: str) -> list:
257
+ """Parse per-column units from y_label like '(kJ/mol), (K), (bar), (kg/m^3)'."""
258
+ return re.findall(r'\(([^)]+)\)', y_label)
259
+
260
+ def _build_y_titles(y_cols: list, units: list) -> dict:
261
+ """Build column-name -> 'Name (unit)' map from y_label units."""
262
+ result = {}
263
+ for idx, col in enumerate(y_cols):
264
+ if idx < len(units):
265
+ result[col] = f'{col} ({clean_gromacs_label(units[idx])})'
266
+ else:
267
+ result[col] = col
268
+ return result
gmxplot/stats.py ADDED
@@ -0,0 +1,84 @@
1
+ """Statistical summary generation for XVG data."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ import pandas as pd
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from .parser import read_xvg
12
+
13
+
14
+ def compute_column_stats(df: pd.DataFrame, metadata) -> List[dict]:
15
+ """Compute per-column statistics: mean, std, min, q10, q50, q90, max."""
16
+ results = []
17
+ fp = Path(metadata.file_path)
18
+ for col in df.columns[1:]:
19
+ series = df[col].dropna()
20
+ results.append({
21
+ 'dir': str(fp.parent),
22
+ 'file': fp.name,
23
+ 'plot': col,
24
+ 'mean': series.mean(),
25
+ 'std': series.std(),
26
+ 'min': series.min(),
27
+ 'q10': series.quantile(0.1),
28
+ 'q50': series.quantile(0.5),
29
+ 'q90': series.quantile(0.9),
30
+ 'max': series.max(),
31
+ })
32
+ return results
33
+
34
+
35
+ def generate_stats_csv(
36
+ file_paths: List[str],
37
+ output_path: str,
38
+ ) -> pd.DataFrame:
39
+ """Generate statistical summary CSV for multiple XVG files."""
40
+ all_stats = []
41
+ for fp in file_paths:
42
+ if not os.path.exists(fp):
43
+ continue
44
+ df, metadata = read_xvg(fp)
45
+ if df.empty:
46
+ continue
47
+ all_stats.extend(compute_column_stats(df, metadata))
48
+
49
+ stats_df = pd.DataFrame(all_stats)
50
+ if not stats_df.empty:
51
+ stats_df.to_csv(output_path, index=False, float_format='%.6f')
52
+ return stats_df
53
+
54
+
55
+ def format_stats_table(stats_df: pd.DataFrame, console: Console = None) -> Table:
56
+ """Format statistics DataFrame as a Rich console table."""
57
+ if console is None:
58
+ console = Console()
59
+
60
+ table = Table(title='XVG Statistics Summary')
61
+ table.add_column('File', style='cyan')
62
+ table.add_column('Plot', style='green')
63
+ table.add_column('Mean', justify='right')
64
+ table.add_column('Std', justify='right')
65
+ table.add_column('Min', justify='right')
66
+ table.add_column('Q10', justify='right')
67
+ table.add_column('Q50', justify='right')
68
+ table.add_column('Q90', justify='right')
69
+ table.add_column('Max', justify='right')
70
+
71
+ for _, row in stats_df.iterrows():
72
+ table.add_row(
73
+ str(row['file']),
74
+ str(row['plot']),
75
+ f'{row["mean"]:.4f}',
76
+ f'{row["std"]:.4f}',
77
+ f'{row["min"]:.4f}',
78
+ f'{row["q10"]:.4f}',
79
+ f'{row["q50"]:.4f}',
80
+ f'{row["q90"]:.4f}',
81
+ f'{row["max"]:.4f}',
82
+ )
83
+
84
+ return table