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/escape_codes.py
ADDED
|
@@ -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
|