multifunctionplotter 1.0.3__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.
- multifunctionplotter/mfp.py +989 -0
- multifunctionplotter/mfp_data_manipulator.py +192 -0
- multifunctionplotter/mfp_dmanp.py +931 -0
- multifunctionplotter/mfp_dmanp_help.py +741 -0
- multifunctionplotter/mfp_help.py +396 -0
- multifunctionplotter/mfp_server.py +603 -0
- multifunctionplotter/prophet_pred.py +214 -0
- multifunctionplotter-1.0.3.dist-info/METADATA +881 -0
- multifunctionplotter-1.0.3.dist-info/RECORD +13 -0
- multifunctionplotter-1.0.3.dist-info/WHEEL +5 -0
- multifunctionplotter-1.0.3.dist-info/entry_points.txt +3 -0
- multifunctionplotter-1.0.3.dist-info/licenses/LICENSE +201 -0
- multifunctionplotter-1.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MultiFunctionPlotter (MFP) — A versatile tool for data visualization.
|
|
4
|
+
|
|
5
|
+
Developed by Swarnadeep Seth.
|
|
6
|
+
Version: 1.0.3
|
|
7
|
+
Date: June 19, 2024
|
|
8
|
+
|
|
9
|
+
Description:
|
|
10
|
+
MultiFunctionPlotter (MFP) simplifies the creation of a wide range of plots
|
|
11
|
+
from CSV and text files, as well as custom mathematical functions. With support
|
|
12
|
+
for multiple plot styles and easy-to-use command-line configuration, MFP aims
|
|
13
|
+
to be a versatile and powerful tool for data visualization.
|
|
14
|
+
|
|
15
|
+
New in v1.0.3:
|
|
16
|
+
• Error bars — discrete bars (errorbars/eb) or shaded band (errorshade/es)
|
|
17
|
+
• Log scale — --xlog / --ylog global flags
|
|
18
|
+
• Colormap scatter — scatter with cmap <col> and colormap <n>
|
|
19
|
+
• 2-D styles — heatmap, contour, contourf read an entire matrix file
|
|
20
|
+
• Help system — --help / --list-styles (backed by mfp_help.py)
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python mfp.py --help
|
|
24
|
+
python mfp.py --list-styles
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# ── Standard library ──────────────────────────────────────────────────────────
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Optional, Union
|
|
36
|
+
|
|
37
|
+
# ── Third-party ───────────────────────────────────────────────────────────────
|
|
38
|
+
import matplotlib.pyplot as plt
|
|
39
|
+
import numpy as np
|
|
40
|
+
import pandas as pd
|
|
41
|
+
import seaborn as sns
|
|
42
|
+
|
|
43
|
+
# ── Project paths ─────────────────────────────────────────────────────────────
|
|
44
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
45
|
+
|
|
46
|
+
# ── Matplotlib style ──────────────────────────────────────────────────────────
|
|
47
|
+
plt.style.use("custom_style")
|
|
48
|
+
|
|
49
|
+
# ── Logging ───────────────────────────────────────────────────────────────────
|
|
50
|
+
logging.basicConfig(
|
|
51
|
+
level=logging.INFO,
|
|
52
|
+
format="%(levelname)s | %(message)s",
|
|
53
|
+
)
|
|
54
|
+
log = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
# Constants
|
|
59
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
60
|
+
|
|
61
|
+
DEFAULT_FIGSIZE = (12, 6)
|
|
62
|
+
JSON_OUTPUT_PATH = Path("plot.json")
|
|
63
|
+
|
|
64
|
+
#: Maps gnuplot-style style names → matplotlib linestyle/marker strings.
|
|
65
|
+
STYLE_MAP: dict[str, str] = {
|
|
66
|
+
"lines": "-",
|
|
67
|
+
"l": "-",
|
|
68
|
+
"dashed": "--",
|
|
69
|
+
"dotted": ":",
|
|
70
|
+
"points": "o",
|
|
71
|
+
"p": "o",
|
|
72
|
+
"linespoints": "-o",
|
|
73
|
+
"lp": "-o",
|
|
74
|
+
"stars": "*",
|
|
75
|
+
"d": "d",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#: Seaborn distribution / summary styles (single column, no y needed).
|
|
79
|
+
SEABORN_STYLES = {"hist", "kde", "box", "violin"}
|
|
80
|
+
|
|
81
|
+
#: Styles that draw error bars / bands around y_data.
|
|
82
|
+
ERRORBAR_STYLES = {"errorbars", "eb", "errorshade", "es"}
|
|
83
|
+
|
|
84
|
+
#: Styles that map a third column to colour.
|
|
85
|
+
COLORMAP_STYLES = {"scatter"}
|
|
86
|
+
|
|
87
|
+
#: Styles that read the whole file as a 2-D matrix.
|
|
88
|
+
TWO_D_STYLES = {"heatmap", "contour", "contourf"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
92
|
+
# PlotConfig — single source of truth for all plot parameters
|
|
93
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class PlotConfig:
|
|
97
|
+
"""Holds every configurable parameter for a single plot command.
|
|
98
|
+
|
|
99
|
+
Adding a new parameter: add it here once, then update CommandParser
|
|
100
|
+
and JsonParser.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# ── Data source ───────────────────────────────────────────────────────────
|
|
104
|
+
file: Optional[str] = None
|
|
105
|
+
x_col: Optional[int] = None
|
|
106
|
+
y_col: Optional[int] = None
|
|
107
|
+
|
|
108
|
+
# ── Math function (alternative to file) ──────────────────────────────────
|
|
109
|
+
function: Optional[str] = None
|
|
110
|
+
func_parameters: dict = field(default_factory=dict)
|
|
111
|
+
|
|
112
|
+
# ── Appearance ────────────────────────────────────────────────────────────
|
|
113
|
+
style: str = "lines"
|
|
114
|
+
linewidth: int = 2
|
|
115
|
+
linecolor: Optional[str] = None
|
|
116
|
+
#: Figure size as (width, height).
|
|
117
|
+
figsize: Optional[tuple[int, int]] = None
|
|
118
|
+
|
|
119
|
+
# ── Labels ────────────────────────────────────────────────────────────────
|
|
120
|
+
title: Optional[str] = None
|
|
121
|
+
xlabel: str = "X-axis"
|
|
122
|
+
ylabel: str = "Y-axis"
|
|
123
|
+
legend: Optional[str] = None
|
|
124
|
+
|
|
125
|
+
# ── Font sizes ────────────────────────────────────────────────────────────
|
|
126
|
+
title_font_size: int = 20
|
|
127
|
+
axis_font_size: int = 18
|
|
128
|
+
tick_font_size: int = 14
|
|
129
|
+
|
|
130
|
+
# ── Axes ranges & histogram bins ─────────────────────────────────────────
|
|
131
|
+
xrange: Optional[list[int]] = None
|
|
132
|
+
yrange: Optional[list[int]] = None
|
|
133
|
+
bin: Union[int, str] = "auto"
|
|
134
|
+
|
|
135
|
+
# ── Error bars ────────────────────────────────────────────────────────────
|
|
136
|
+
#: 1-based column index holding the ±σ (or ±error) values.
|
|
137
|
+
yerr_col: Optional[int] = None
|
|
138
|
+
#: Cap width in points for discrete error bars (errorbars style).
|
|
139
|
+
capsize: int = 4
|
|
140
|
+
|
|
141
|
+
# ── Colormap scatter ─────────────────────────────────────────────────────
|
|
142
|
+
#: 1-based column index whose values drive the point colour.
|
|
143
|
+
cmap_col: Optional[int] = None
|
|
144
|
+
#: Matplotlib colormap name for scatter / 2-D plots.
|
|
145
|
+
colormap: str = "viridis"
|
|
146
|
+
#: Label for the colorbar (scatter and 2-D styles).
|
|
147
|
+
cbar_label: Optional[str] = None
|
|
148
|
+
|
|
149
|
+
# ── 2-D / matrix styles ───────────────────────────────────────────────────
|
|
150
|
+
#: Number of contour levels (contour / contourf).
|
|
151
|
+
levels: int = 10
|
|
152
|
+
|
|
153
|
+
# ── Advanced axis formatting ──────────────────────────────────────────────
|
|
154
|
+
#: Date/time parsing: "auto", "%Y-%m-%d", "%d/%m/%Y", etc.
|
|
155
|
+
date_format: Optional[str] = None
|
|
156
|
+
#: Scientific notation for axes: "x", "y", or "both".
|
|
157
|
+
sci_notation: Optional[str] = None
|
|
158
|
+
#: Custom x-axis tick positions: "0,90,180,270" or similar.
|
|
159
|
+
xticks: Optional[str] = None
|
|
160
|
+
#: Custom y-axis tick positions.
|
|
161
|
+
yticks: Optional[str] = None
|
|
162
|
+
#: Rotate x-axis labels: angle in degrees (e.g., 45).
|
|
163
|
+
xtick_rotation: int = 0
|
|
164
|
+
#: Rotate y-axis labels: angle in degrees.
|
|
165
|
+
ytick_rotation: int = 0
|
|
166
|
+
|
|
167
|
+
# ── Derived helpers ───────────────────────────────────────────────────────
|
|
168
|
+
@property
|
|
169
|
+
def mpl_style(self) -> str:
|
|
170
|
+
"""Return the matplotlib linestyle/marker string for this config."""
|
|
171
|
+
return STYLE_MAP.get(self.style, "-")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
175
|
+
# Parsers — convert raw input into a PlotConfig
|
|
176
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
177
|
+
|
|
178
|
+
class CommandParser:
|
|
179
|
+
"""Parse a gnuplot-style command string into a PlotConfig."""
|
|
180
|
+
|
|
181
|
+
# Pre-compiled regex patterns
|
|
182
|
+
_RE_CSV = re.compile(r"(\S+\.csv)")
|
|
183
|
+
_RE_TEXT = re.compile(r"(\S+\.(?:txt|dat))")
|
|
184
|
+
_RE_USING = re.compile(r"(?:using|u) (\d+):(\d+)")
|
|
185
|
+
_RE_STYLE = re.compile(r"(?:with|w) (\w+)")
|
|
186
|
+
_RE_TITLE = re.compile(r'title "(.+?)"')
|
|
187
|
+
_RE_XLABEL = re.compile(r'xlabel "(.+?)"')
|
|
188
|
+
_RE_YLABEL = re.compile(r'ylabel "(.+?)"')
|
|
189
|
+
_RE_LW = re.compile(r"(?:linewidth|lw) (\d+)")
|
|
190
|
+
_RE_LC = re.compile(r"(?:linecolor|lc) (\S+)")
|
|
191
|
+
_RE_FIGSIZE = re.compile(r"figsize (\d+):(\d+)")
|
|
192
|
+
_RE_LEGEND = re.compile(r"(?:legend|lg) (\S+)")
|
|
193
|
+
_RE_FUNC = re.compile(r'func: ("[^"]+"|.+?)(?=\s+xrange|\s+yrange|\s+title|\s+xlabel|\s+ylabel|\s+legend|\s+lg|\s+with|\s+w|\s+linecolor|\s+lc|\s+linewidth|\s+lw|\s+-\d|$)')
|
|
194
|
+
_RE_XRANGE = re.compile(r"xrange (-?\d+):(-?\d+)")
|
|
195
|
+
_RE_YRANGE = re.compile(r"yrange (-?\d+):(-?\d+)")
|
|
196
|
+
_RE_BIN = re.compile(r"bin (\d+)")
|
|
197
|
+
_RE_PARAMS = re.compile(r"(\w+)=([\d.]+)")
|
|
198
|
+
# ── New tokens v1.1 ───────────────────────────────────────────────────────
|
|
199
|
+
_RE_YERR = re.compile(r"yerr (\d+)")
|
|
200
|
+
_RE_CAPSIZE = re.compile(r"capsize (\d+)")
|
|
201
|
+
_RE_CMAP_COL = re.compile(r"cmap (\d+)")
|
|
202
|
+
_RE_COLORMAP = re.compile(r"colormap (\S+)")
|
|
203
|
+
_RE_CBAR_LABEL = re.compile(r'cbar_label "(.+?)"')
|
|
204
|
+
_RE_LEVELS = re.compile(r"levels (\d+)")
|
|
205
|
+
|
|
206
|
+
# ── Advanced axis formatting v1.2 ──────────────────────────────────────────
|
|
207
|
+
_RE_DATE_FORMAT = re.compile(r'date_format ("[^"]+"|\S+)')
|
|
208
|
+
_RE_SCI_NOTATION = re.compile(r"sci_notation (\S+)")
|
|
209
|
+
_RE_XTICKS = re.compile(r'xticks ("[^"]+"|\S+)')
|
|
210
|
+
_RE_YTICKS = re.compile(r'yticks ("[^"]+"|\S+)')
|
|
211
|
+
_RE_XTICK_ROT = re.compile(r"xtick_rotation (-?\d+)")
|
|
212
|
+
_RE_YTICK_ROT = re.compile(r"ytick_rotation (-?\d+)")
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def parse(cls, command: str) -> PlotConfig:
|
|
216
|
+
"""Parse *command* and return a populated PlotConfig.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: If mandatory fields are missing.
|
|
220
|
+
"""
|
|
221
|
+
log.info("Parsing command: %s", command)
|
|
222
|
+
cfg = PlotConfig()
|
|
223
|
+
|
|
224
|
+
# ── File ──────────────────────────────────────────────────────────────
|
|
225
|
+
if m := cls._RE_CSV.search(command):
|
|
226
|
+
cfg.file = m.group(1)
|
|
227
|
+
elif m := cls._RE_TEXT.search(command):
|
|
228
|
+
cfg.file = m.group(1)
|
|
229
|
+
|
|
230
|
+
# ── Columns ───────────────────────────────────────────────────────────
|
|
231
|
+
if m := cls._RE_USING.search(command):
|
|
232
|
+
cfg.x_col = int(m.group(1))
|
|
233
|
+
cfg.y_col = int(m.group(2))
|
|
234
|
+
|
|
235
|
+
# ── Style / appearance ────────────────────────────────────────────────
|
|
236
|
+
if m := cls._RE_STYLE.search(command):
|
|
237
|
+
cfg.style = m.group(1)
|
|
238
|
+
if m := cls._RE_LW.search(command):
|
|
239
|
+
cfg.linewidth = int(m.group(1))
|
|
240
|
+
if m := cls._RE_LC.search(command):
|
|
241
|
+
cfg.linecolor = m.group(1)
|
|
242
|
+
if m := cls._RE_FIGSIZE.search(command):
|
|
243
|
+
cfg.figsize = (int(m.group(1)), int(m.group(2)))
|
|
244
|
+
|
|
245
|
+
# ── Labels ────────────────────────────────────────────────────────────
|
|
246
|
+
if m := cls._RE_TITLE.search(command):
|
|
247
|
+
cfg.title = m.group(1)
|
|
248
|
+
if m := cls._RE_XLABEL.search(command):
|
|
249
|
+
cfg.xlabel = m.group(1)
|
|
250
|
+
if m := cls._RE_YLABEL.search(command):
|
|
251
|
+
cfg.ylabel = m.group(1)
|
|
252
|
+
if m := cls._RE_LEGEND.search(command):
|
|
253
|
+
cfg.legend = m.group(1)
|
|
254
|
+
|
|
255
|
+
# ── Math function ─────────────────────────────────────────────────────
|
|
256
|
+
if m := cls._RE_FUNC.search(command):
|
|
257
|
+
cfg.function = m.group(1).strip('"')
|
|
258
|
+
cfg.func_parameters = dict(cls._RE_PARAMS.findall(cfg.function))
|
|
259
|
+
|
|
260
|
+
# ── Ranges / bins ─────────────────────────────────────────────────────
|
|
261
|
+
if m := cls._RE_XRANGE.search(command):
|
|
262
|
+
cfg.xrange = [int(m.group(1)), int(m.group(2))]
|
|
263
|
+
if m := cls._RE_YRANGE.search(command):
|
|
264
|
+
cfg.yrange = [int(m.group(1)), int(m.group(2))]
|
|
265
|
+
if m := cls._RE_BIN.search(command):
|
|
266
|
+
cfg.bin = int(m.group(1))
|
|
267
|
+
|
|
268
|
+
# ── Error bars ────────────────────────────────────────────────────────
|
|
269
|
+
if m := cls._RE_YERR.search(command):
|
|
270
|
+
cfg.yerr_col = int(m.group(1))
|
|
271
|
+
if m := cls._RE_CAPSIZE.search(command):
|
|
272
|
+
cfg.capsize = int(m.group(1))
|
|
273
|
+
|
|
274
|
+
# ── Colormap ──────────────────────────────────────────────────────────
|
|
275
|
+
if m := cls._RE_CMAP_COL.search(command):
|
|
276
|
+
cfg.cmap_col = int(m.group(1))
|
|
277
|
+
if m := cls._RE_COLORMAP.search(command):
|
|
278
|
+
cfg.colormap = m.group(1)
|
|
279
|
+
if m := cls._RE_CBAR_LABEL.search(command):
|
|
280
|
+
cfg.cbar_label = m.group(1)
|
|
281
|
+
|
|
282
|
+
# ── 2-D ───────────────────────────────────────────────────────────────
|
|
283
|
+
if m := cls._RE_LEVELS.search(command):
|
|
284
|
+
cfg.levels = int(m.group(1))
|
|
285
|
+
|
|
286
|
+
# ── Advanced axis formatting ──────────────────────────────────────────
|
|
287
|
+
if m := cls._RE_DATE_FORMAT.search(command):
|
|
288
|
+
cfg.date_format = m.group(1).strip('"')
|
|
289
|
+
if m := cls._RE_SCI_NOTATION.search(command):
|
|
290
|
+
cfg.sci_notation = m.group(1)
|
|
291
|
+
if m := cls._RE_XTICKS.search(command):
|
|
292
|
+
cfg.xticks = m.group(1).strip('"')
|
|
293
|
+
if m := cls._RE_YTICKS.search(command):
|
|
294
|
+
cfg.yticks = m.group(1).strip('"')
|
|
295
|
+
if m := cls._RE_XTICK_ROT.search(command):
|
|
296
|
+
cfg.xtick_rotation = int(m.group(1))
|
|
297
|
+
if m := cls._RE_YTICK_ROT.search(command):
|
|
298
|
+
cfg.ytick_rotation = int(m.group(1))
|
|
299
|
+
|
|
300
|
+
cls._validate(cfg)
|
|
301
|
+
return cfg
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _validate(cfg: PlotConfig) -> None:
|
|
305
|
+
style = cfg.style
|
|
306
|
+
|
|
307
|
+
# 2-D styles only need a file, no column spec required.
|
|
308
|
+
if style in TWO_D_STYLES:
|
|
309
|
+
if not cfg.file:
|
|
310
|
+
raise ValueError(f"Style '{style}' requires a file.")
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# Error-bar styles need yerr_col.
|
|
314
|
+
if style in ERRORBAR_STYLES and cfg.yerr_col is None:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
f"Style '{style}' requires yerr <col> in the command."
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Colormap scatter needs cmap_col.
|
|
320
|
+
if style in COLORMAP_STYLES and cfg.cmap_col is None:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
"Style 'scatter' requires cmap <col> in the command."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Standard styles need file + columns (unless func:).
|
|
326
|
+
if not cfg.function:
|
|
327
|
+
if not cfg.file or cfg.x_col is None or cfg.y_col is None:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
"File and columns (x, y) must be specified when not using func:."
|
|
330
|
+
)
|
|
331
|
+
if not cfg.file and not cfg.function:
|
|
332
|
+
raise ValueError("Either a file or a func: expression must be provided.")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class JsonParser:
|
|
336
|
+
"""Build a PlotConfig from a JSON-derived dictionary."""
|
|
337
|
+
|
|
338
|
+
@classmethod
|
|
339
|
+
def parse(cls, data: dict) -> PlotConfig:
|
|
340
|
+
"""Parse *data* (a JSON object) and return a PlotConfig."""
|
|
341
|
+
log.info("Parsing JSON command: %s", data)
|
|
342
|
+
cfg = PlotConfig(
|
|
343
|
+
file = data.get("file"),
|
|
344
|
+
x_col = data.get("x_col"),
|
|
345
|
+
y_col = data.get("y_col"),
|
|
346
|
+
style = data.get("style", "lines"),
|
|
347
|
+
linewidth = data.get("linewidth", 2),
|
|
348
|
+
linecolor = data.get("linecolor", "tab:blue"),
|
|
349
|
+
figsize = tuple(data.get("figsize")) if data.get("figsize") else None,
|
|
350
|
+
title = data.get("title"),
|
|
351
|
+
xlabel = data.get("xlabel", "X-axis"),
|
|
352
|
+
ylabel = data.get("ylabel", "Y-axis"),
|
|
353
|
+
title_font_size = data.get("title_font_size", 20),
|
|
354
|
+
axis_font_size = data.get("axis_font_size", 18),
|
|
355
|
+
tick_font_size = data.get("tick_font_size", 14),
|
|
356
|
+
legend = data.get("legend"),
|
|
357
|
+
xrange = data.get("xrange"),
|
|
358
|
+
yrange = data.get("yrange"),
|
|
359
|
+
bin = data.get("bin", 10),
|
|
360
|
+
# v1.1 fields — safe to omit in old JSON files (defaults apply).
|
|
361
|
+
yerr_col = data.get("yerr_col"),
|
|
362
|
+
capsize = data.get("capsize", 4),
|
|
363
|
+
cmap_col = data.get("cmap_col"),
|
|
364
|
+
colormap = data.get("colormap", "viridis"),
|
|
365
|
+
cbar_label = data.get("cbar_label"),
|
|
366
|
+
levels = data.get("levels", 10),
|
|
367
|
+
# v1.2 fields — axis formatting
|
|
368
|
+
date_format = data.get("date_format"),
|
|
369
|
+
sci_notation = data.get("sci_notation"),
|
|
370
|
+
xticks = data.get("xticks"),
|
|
371
|
+
yticks = data.get("yticks"),
|
|
372
|
+
xtick_rotation = data.get("xtick_rotation", 0),
|
|
373
|
+
ytick_rotation = data.get("ytick_rotation", 0),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if cfg.style not in TWO_D_STYLES:
|
|
377
|
+
if not cfg.file or cfg.x_col is None or cfg.y_col is None:
|
|
378
|
+
raise ValueError(
|
|
379
|
+
"JSON command must contain 'file', 'x_col', and 'y_col'."
|
|
380
|
+
)
|
|
381
|
+
return cfg
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
385
|
+
# Plotter — renders a PlotConfig onto a matplotlib figure / axes
|
|
386
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
387
|
+
|
|
388
|
+
class Plotter:
|
|
389
|
+
"""Loads data and draws plots described by a PlotConfig."""
|
|
390
|
+
|
|
391
|
+
def __init__(self, cfg: PlotConfig) -> None:
|
|
392
|
+
self.cfg = cfg
|
|
393
|
+
self.data: Optional[pd.DataFrame] = None
|
|
394
|
+
|
|
395
|
+
if cfg.file:
|
|
396
|
+
self._load_data()
|
|
397
|
+
|
|
398
|
+
# ── Data loading ──────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
def _load_data(self) -> None:
|
|
401
|
+
if self.cfg.style in TWO_D_STYLES:
|
|
402
|
+
self._load_matrix()
|
|
403
|
+
elif self.cfg.file.endswith(".csv"):
|
|
404
|
+
self._load_csv()
|
|
405
|
+
else:
|
|
406
|
+
self._load_text()
|
|
407
|
+
|
|
408
|
+
def _load_csv(self) -> None:
|
|
409
|
+
self.data = pd.read_csv(self.cfg.file)
|
|
410
|
+
|
|
411
|
+
def _load_text(self) -> None:
|
|
412
|
+
try:
|
|
413
|
+
self.data = pd.read_csv(self.cfg.file, delimiter=r"\s+", header=None)
|
|
414
|
+
except Exception:
|
|
415
|
+
self.data = pd.read_csv(self.cfg.file, delimiter="\t", header=None)
|
|
416
|
+
|
|
417
|
+
def _load_matrix(self) -> None:
|
|
418
|
+
"""Load a headerless numeric matrix for 2-D plot styles."""
|
|
419
|
+
try:
|
|
420
|
+
self.data = pd.read_csv(self.cfg.file, delimiter=r"\s+", header=None)
|
|
421
|
+
except Exception:
|
|
422
|
+
self.data = pd.read_csv(self.cfg.file, header=None)
|
|
423
|
+
|
|
424
|
+
# ── Column selection helpers ───────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
def _get_x_data(self, one_based: bool = False) -> pd.Series:
|
|
427
|
+
if self.cfg.x_col == 0:
|
|
428
|
+
return self.data.index
|
|
429
|
+
offset = 1 if one_based else 0
|
|
430
|
+
return self.data.iloc[:, self.cfg.x_col - offset]
|
|
431
|
+
|
|
432
|
+
def _get_y_data(self, one_based: bool = False) -> pd.Series:
|
|
433
|
+
offset = 1 if one_based else 0
|
|
434
|
+
return self.data.iloc[:, self.cfg.y_col - offset]
|
|
435
|
+
|
|
436
|
+
def _get_err_data(self, one_based: bool = False) -> Optional[pd.Series]:
|
|
437
|
+
if self.cfg.yerr_col is None:
|
|
438
|
+
return None
|
|
439
|
+
offset = 1 if one_based else 0
|
|
440
|
+
return self.data.iloc[:, self.cfg.yerr_col - offset]
|
|
441
|
+
|
|
442
|
+
def _get_cmap_data(self, one_based: bool = False) -> Optional[pd.Series]:
|
|
443
|
+
if self.cfg.cmap_col is None:
|
|
444
|
+
return None
|
|
445
|
+
offset = 1 if one_based else 0
|
|
446
|
+
return self.data.iloc[:, self.cfg.cmap_col - offset]
|
|
447
|
+
|
|
448
|
+
# ── Axis decoration ───────────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
def _apply_axis_decorations(self, ax=None) -> None:
|
|
451
|
+
"""Apply title, labels, ranges, tick sizes, legend, and advanced formatting.
|
|
452
|
+
|
|
453
|
+
ax=None → current pyplot axes (single-panel mode).
|
|
454
|
+
ax=<Axes> → explicit axes (subplot mode).
|
|
455
|
+
"""
|
|
456
|
+
if ax is None:
|
|
457
|
+
set_title = plt.title
|
|
458
|
+
set_xlabel = plt.xlabel
|
|
459
|
+
set_ylabel = plt.ylabel
|
|
460
|
+
tick_params = plt.tick_params
|
|
461
|
+
set_xlim = plt.xlim
|
|
462
|
+
set_ylim = plt.ylim
|
|
463
|
+
legend_fn = plt.legend
|
|
464
|
+
ax_obj = plt.gca()
|
|
465
|
+
else:
|
|
466
|
+
set_title = ax.set_title
|
|
467
|
+
set_xlabel = ax.set_xlabel
|
|
468
|
+
set_ylabel = ax.set_ylabel
|
|
469
|
+
tick_params = ax.tick_params
|
|
470
|
+
set_xlim = ax.set_xlim
|
|
471
|
+
set_ylim = ax.set_ylim
|
|
472
|
+
legend_fn = ax.legend
|
|
473
|
+
ax_obj = ax
|
|
474
|
+
|
|
475
|
+
set_title(self.cfg.title or "", fontsize=self.cfg.title_font_size)
|
|
476
|
+
set_xlabel(self.cfg.xlabel, fontsize=self.cfg.axis_font_size)
|
|
477
|
+
set_ylabel(self.cfg.ylabel, fontsize=self.cfg.axis_font_size)
|
|
478
|
+
tick_params(labelsize=self.cfg.tick_font_size)
|
|
479
|
+
|
|
480
|
+
if self.cfg.xrange:
|
|
481
|
+
set_xlim(self.cfg.xrange)
|
|
482
|
+
if self.cfg.yrange:
|
|
483
|
+
set_ylim(self.cfg.yrange)
|
|
484
|
+
if self.cfg.legend:
|
|
485
|
+
legend_fn(frameon=False, fontsize=self.cfg.axis_font_size)
|
|
486
|
+
|
|
487
|
+
# ── Advanced axis formatting ──────────────────────────────────────────
|
|
488
|
+
self._apply_advanced_formatting(ax_obj)
|
|
489
|
+
|
|
490
|
+
def _apply_advanced_formatting(self, ax) -> None:
|
|
491
|
+
"""Apply scientific notation, custom ticks, rotations, date formatting."""
|
|
492
|
+
import matplotlib.ticker as ticker
|
|
493
|
+
from matplotlib.dates import DateFormatter, AutoDateLocator
|
|
494
|
+
|
|
495
|
+
# ── Scientific notation ───────────────────────────────────────────────
|
|
496
|
+
if self.cfg.sci_notation in {"x", "both"}:
|
|
497
|
+
ax.xaxis.set_major_formatter(ticker.ScalarFormatter(useMathText=True))
|
|
498
|
+
ax.ticklabel_format(style="sci", axis="x", scilimits=(0, 0))
|
|
499
|
+
log.info("X-axis: scientific notation enabled.")
|
|
500
|
+
|
|
501
|
+
if self.cfg.sci_notation in {"y", "both"}:
|
|
502
|
+
ax.yaxis.set_major_formatter(ticker.ScalarFormatter(useMathText=True))
|
|
503
|
+
ax.ticklabel_format(style="sci", axis="y", scilimits=(0, 0))
|
|
504
|
+
log.info("Y-axis: scientific notation enabled.")
|
|
505
|
+
|
|
506
|
+
# ── Custom ticks ──────────────────────────────────────────────────────
|
|
507
|
+
if self.cfg.xticks:
|
|
508
|
+
try:
|
|
509
|
+
x_positions = [float(x.strip()) for x in self.cfg.xticks.split(",")]
|
|
510
|
+
ax.set_xticks(x_positions)
|
|
511
|
+
log.info("X-axis custom ticks: %s", x_positions)
|
|
512
|
+
except ValueError:
|
|
513
|
+
log.error("Invalid xticks format. Use comma-separated numbers.")
|
|
514
|
+
|
|
515
|
+
if self.cfg.yticks:
|
|
516
|
+
try:
|
|
517
|
+
y_positions = [float(y.strip()) for y in self.cfg.yticks.split(",")]
|
|
518
|
+
ax.set_yticks(y_positions)
|
|
519
|
+
log.info("Y-axis custom ticks: %s", y_positions)
|
|
520
|
+
except ValueError:
|
|
521
|
+
log.error("Invalid yticks format. Use comma-separated numbers.")
|
|
522
|
+
|
|
523
|
+
# ── Tick rotation ─────────────────────────────────────────────────────
|
|
524
|
+
if self.cfg.xtick_rotation != 0:
|
|
525
|
+
ax.tick_params(axis="x", rotation=self.cfg.xtick_rotation)
|
|
526
|
+
log.info("X-axis ticks rotated by %d°.", self.cfg.xtick_rotation)
|
|
527
|
+
|
|
528
|
+
if self.cfg.ytick_rotation != 0:
|
|
529
|
+
ax.tick_params(axis="y", rotation=self.cfg.ytick_rotation)
|
|
530
|
+
log.info("Y-axis ticks rotated by %d°.", self.cfg.ytick_rotation)
|
|
531
|
+
|
|
532
|
+
# ── Date formatting ───────────────────────────────────────────────────
|
|
533
|
+
if self.cfg.date_format:
|
|
534
|
+
try:
|
|
535
|
+
ax.xaxis.set_major_formatter(DateFormatter(self.cfg.date_format))
|
|
536
|
+
ax.xaxis.set_major_locator(AutoDateLocator())
|
|
537
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
|
|
538
|
+
log.info("X-axis date format: %s", self.cfg.date_format)
|
|
539
|
+
except Exception as e:
|
|
540
|
+
log.error("Failed to apply date format: %s", e)
|
|
541
|
+
|
|
542
|
+
# ── Core plot renderers ───────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
def plot(self) -> None:
|
|
545
|
+
"""Render a standard single-panel plot."""
|
|
546
|
+
log.info("Plotting columns [%s, %s]", self.cfg.x_col, self.cfg.y_col)
|
|
547
|
+
|
|
548
|
+
# For 2D styles (heatmap, contour, contourf), don't need x/y data
|
|
549
|
+
if self.cfg.style in TWO_D_STYLES:
|
|
550
|
+
self._draw_series(None, None, err=None, cmap_z=None, ax=None)
|
|
551
|
+
else:
|
|
552
|
+
one_based = not (self.cfg.file or "").endswith(".csv")
|
|
553
|
+
x_data = self._get_x_data(one_based=one_based)
|
|
554
|
+
y_data = self._get_y_data(one_based=one_based)
|
|
555
|
+
err = self._get_err_data(one_based=one_based)
|
|
556
|
+
cmap_z = self._get_cmap_data(one_based=one_based)
|
|
557
|
+
self._draw_series(x_data, y_data, err=err, cmap_z=cmap_z, ax=None)
|
|
558
|
+
|
|
559
|
+
self._apply_axis_decorations(ax=None)
|
|
560
|
+
|
|
561
|
+
def function_plot(self) -> None:
|
|
562
|
+
"""Evaluate and render a mathematical function over xrange."""
|
|
563
|
+
if not self.cfg.xrange:
|
|
564
|
+
raise ValueError("xrange must be specified for function plots.")
|
|
565
|
+
|
|
566
|
+
x0, x1 = self.cfg.xrange
|
|
567
|
+
x = np.linspace(x0, x1, (x1 - x0) * 10)
|
|
568
|
+
|
|
569
|
+
func_str = re.split(r"\)\s*=", self.cfg.function, maxsplit=1)[-1]
|
|
570
|
+
params = {k: float(v) for k, v in self.cfg.func_parameters.items()}
|
|
571
|
+
y = eval(func_str, {"x": x, "np": np, **params}) # noqa: S307
|
|
572
|
+
|
|
573
|
+
plt.plot(x, y, self.cfg.mpl_style,
|
|
574
|
+
linewidth=self.cfg.linewidth,
|
|
575
|
+
color=self.cfg.linecolor,
|
|
576
|
+
label=self.cfg.legend)
|
|
577
|
+
self._apply_axis_decorations(ax=None)
|
|
578
|
+
|
|
579
|
+
def subplot_mosaic(self, ax) -> None:
|
|
580
|
+
"""Render this plot into subplot axes *ax*."""
|
|
581
|
+
x_data = self._get_x_data(one_based=False)
|
|
582
|
+
y_data = self.data.iloc[:, self.cfg.y_col]
|
|
583
|
+
err = self._get_err_data(one_based=False)
|
|
584
|
+
cmap_z = self._get_cmap_data(one_based=False)
|
|
585
|
+
|
|
586
|
+
self._draw_series(x_data, y_data, err=err, cmap_z=cmap_z, ax=ax)
|
|
587
|
+
self._apply_axis_decorations(ax=ax)
|
|
588
|
+
|
|
589
|
+
# ── Private drawing dispatch ──────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
def _draw_series(self, x_data, y_data, *, err=None, cmap_z=None, ax) -> None:
|
|
592
|
+
"""Route to the correct drawing method based on style."""
|
|
593
|
+
style = self.cfg.style
|
|
594
|
+
|
|
595
|
+
if style in TWO_D_STYLES:
|
|
596
|
+
self._draw_2d(ax)
|
|
597
|
+
elif style in ERRORBAR_STYLES:
|
|
598
|
+
self._draw_errorbars(x_data, y_data, err, ax)
|
|
599
|
+
elif style in COLORMAP_STYLES:
|
|
600
|
+
self._draw_colormap_scatter(x_data, y_data, cmap_z, ax)
|
|
601
|
+
elif style in SEABORN_STYLES:
|
|
602
|
+
self._draw_seaborn(style, x_data, ax)
|
|
603
|
+
else:
|
|
604
|
+
self._draw_line(x_data, y_data, ax)
|
|
605
|
+
|
|
606
|
+
# ── Individual draw methods ───────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
def _draw_line(self, x_data, y_data, ax) -> None:
|
|
609
|
+
"""Standard matplotlib line / marker plot."""
|
|
610
|
+
kwargs = dict(linewidth=self.cfg.linewidth,
|
|
611
|
+
color=self.cfg.linecolor,
|
|
612
|
+
label=self.cfg.legend)
|
|
613
|
+
if ax is None:
|
|
614
|
+
plt.plot(x_data, y_data, self.cfg.mpl_style, **kwargs)
|
|
615
|
+
else:
|
|
616
|
+
ax.plot(x_data, y_data, self.cfg.mpl_style, **kwargs)
|
|
617
|
+
|
|
618
|
+
def _draw_errorbars(self, x_data, y_data, err, ax) -> None:
|
|
619
|
+
"""Discrete error bars (errorbars/eb) or shaded band (errorshade/es)."""
|
|
620
|
+
if err is None:
|
|
621
|
+
log.error("yerr column not loaded — skipping error bar plot.")
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
style = self.cfg.style
|
|
625
|
+
color = self.cfg.linecolor
|
|
626
|
+
label = self.cfg.legend
|
|
627
|
+
lw = self.cfg.linewidth
|
|
628
|
+
|
|
629
|
+
if style in {"errorbars", "eb"}:
|
|
630
|
+
log.info("Drawing discrete error bars.")
|
|
631
|
+
kwargs = dict(fmt=self.cfg.mpl_style, linewidth=lw,
|
|
632
|
+
color=color, ecolor=color,
|
|
633
|
+
capsize=self.cfg.capsize, label=label)
|
|
634
|
+
if ax is None:
|
|
635
|
+
plt.errorbar(x_data, y_data, yerr=err, **kwargs)
|
|
636
|
+
else:
|
|
637
|
+
ax.errorbar(x_data, y_data, yerr=err, **kwargs)
|
|
638
|
+
|
|
639
|
+
else: # errorshade / es
|
|
640
|
+
log.info("Drawing shaded error band.")
|
|
641
|
+
if ax is None:
|
|
642
|
+
plt.plot(x_data, y_data, self.cfg.mpl_style,
|
|
643
|
+
linewidth=lw, color=color, label=label)
|
|
644
|
+
plt.fill_between(x_data,
|
|
645
|
+
y_data - err, y_data + err,
|
|
646
|
+
alpha=0.25, color=color)
|
|
647
|
+
else:
|
|
648
|
+
ax.plot(x_data, y_data, self.cfg.mpl_style,
|
|
649
|
+
linewidth=lw, color=color, label=label)
|
|
650
|
+
ax.fill_between(x_data,
|
|
651
|
+
y_data - err, y_data + err,
|
|
652
|
+
alpha=0.25, color=color)
|
|
653
|
+
|
|
654
|
+
def _draw_colormap_scatter(self, x_data, y_data, cmap_z, ax) -> None:
|
|
655
|
+
"""Scatter plot coloured by a third column, with automatic colorbar."""
|
|
656
|
+
if cmap_z is None:
|
|
657
|
+
log.error("cmap column not loaded — skipping colormap scatter.")
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
log.info("Drawing colormap scatter (cmap='%s').", self.cfg.colormap)
|
|
661
|
+
kwargs = dict(c=cmap_z, cmap=self.cfg.colormap,
|
|
662
|
+
label=self.cfg.legend, linewidths=0)
|
|
663
|
+
if ax is None:
|
|
664
|
+
sc = plt.scatter(x_data, y_data, **kwargs)
|
|
665
|
+
cb = plt.colorbar(sc)
|
|
666
|
+
else:
|
|
667
|
+
sc = ax.scatter(x_data, y_data, **kwargs)
|
|
668
|
+
cb = plt.colorbar(sc, ax=ax)
|
|
669
|
+
|
|
670
|
+
if self.cfg.cbar_label:
|
|
671
|
+
cb.set_label(self.cfg.cbar_label, fontsize=self.cfg.axis_font_size)
|
|
672
|
+
|
|
673
|
+
def _draw_2d(self, ax) -> None:
|
|
674
|
+
"""Render the loaded matrix as heatmap, contour, or contourf.
|
|
675
|
+
|
|
676
|
+
Rows → y-axis, columns → x-axis.
|
|
677
|
+
"""
|
|
678
|
+
matrix = self.data.values.astype(float)
|
|
679
|
+
style = self.cfg.style
|
|
680
|
+
cmap = self.cfg.colormap
|
|
681
|
+
levels = self.cfg.levels
|
|
682
|
+
|
|
683
|
+
log.info("Drawing 2-D '%s' (shape=%s).", style, matrix.shape)
|
|
684
|
+
|
|
685
|
+
if style == "heatmap":
|
|
686
|
+
obj = (plt.imshow if ax is None else ax.imshow)(
|
|
687
|
+
matrix, aspect="auto", cmap=cmap, origin="lower"
|
|
688
|
+
)
|
|
689
|
+
elif style == "contour":
|
|
690
|
+
obj = (plt.contour if ax is None else ax.contour)(
|
|
691
|
+
matrix, levels=levels, cmap=cmap
|
|
692
|
+
)
|
|
693
|
+
else: # contourf
|
|
694
|
+
obj = (plt.contourf if ax is None else ax.contourf)(
|
|
695
|
+
matrix, levels=levels, cmap=cmap
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
cb = plt.colorbar(obj, ax=ax)
|
|
699
|
+
if self.cfg.cbar_label:
|
|
700
|
+
cb.set_label(self.cfg.cbar_label, fontsize=self.cfg.axis_font_size)
|
|
701
|
+
|
|
702
|
+
def _draw_seaborn(self, style: str, x_data, ax) -> None:
|
|
703
|
+
"""Dispatch to the correct seaborn distribution function."""
|
|
704
|
+
log.info("Rendering seaborn '%s' plot.", style)
|
|
705
|
+
common = dict(color=self.cfg.linecolor, label=self.cfg.legend)
|
|
706
|
+
if ax is not None:
|
|
707
|
+
common["ax"] = ax
|
|
708
|
+
|
|
709
|
+
if style == "hist":
|
|
710
|
+
sns.histplot(data=x_data, bins=self.cfg.bin, **common)
|
|
711
|
+
elif style == "kde":
|
|
712
|
+
sns.kdeplot(data=x_data, **common)
|
|
713
|
+
elif style == "box":
|
|
714
|
+
sns.boxplot(data=x_data, **common)
|
|
715
|
+
elif style == "violin":
|
|
716
|
+
sns.violinplot(data=x_data, **common)
|
|
717
|
+
|
|
718
|
+
# ── Expression evaluator ──────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
def evaluate_expression(self, expr: str):
|
|
721
|
+
"""Replace $n column references and evaluate the expression."""
|
|
722
|
+
expr = re.sub(
|
|
723
|
+
r"\$(\d+)",
|
|
724
|
+
lambda m: f"self.data.iloc[:, {int(m.group(1)) - 1}]",
|
|
725
|
+
expr,
|
|
726
|
+
)
|
|
727
|
+
return eval(expr) # noqa: S307
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
731
|
+
# Log-scale helper — applied globally after all series are drawn
|
|
732
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
733
|
+
|
|
734
|
+
def apply_log_scale(command: str, axd: Optional[dict] = None) -> None:
|
|
735
|
+
"""Set log scale on x and/or y axes when --xlog / --ylog are present.
|
|
736
|
+
|
|
737
|
+
Works for both single figures and subplot mosaics.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
command: Full raw command string checked for --xlog / --ylog.
|
|
741
|
+
axd: Axes dict from subplot_mosaic, or None for single-panel.
|
|
742
|
+
"""
|
|
743
|
+
xlog = "--xlog" in command
|
|
744
|
+
ylog = "--ylog" in command
|
|
745
|
+
|
|
746
|
+
if not xlog and not ylog:
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
axes = list(axd.values()) if axd else [plt.gca()]
|
|
750
|
+
for ax in axes:
|
|
751
|
+
if xlog:
|
|
752
|
+
ax.set_xscale("log")
|
|
753
|
+
log.info("x-axis set to log scale.")
|
|
754
|
+
if ylog:
|
|
755
|
+
ax.set_yscale("log")
|
|
756
|
+
log.info("y-axis set to log scale.")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
760
|
+
# Top-level processing helpers
|
|
761
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
762
|
+
|
|
763
|
+
def process_plots(commands: list[str]) -> None:
|
|
764
|
+
"""Parse and render a list of command strings; persist configs to JSON."""
|
|
765
|
+
configs: list[PlotConfig] = []
|
|
766
|
+
|
|
767
|
+
for command in commands:
|
|
768
|
+
try:
|
|
769
|
+
cfg = CommandParser.parse(command)
|
|
770
|
+
plotter = Plotter(cfg)
|
|
771
|
+
if cfg.function:
|
|
772
|
+
plotter.function_plot()
|
|
773
|
+
else:
|
|
774
|
+
plotter.plot()
|
|
775
|
+
configs.append(cfg)
|
|
776
|
+
except Exception as exc:
|
|
777
|
+
log.error("Failed to process command %r: %s", command, exc)
|
|
778
|
+
|
|
779
|
+
_save_configs_to_json(configs)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def process_plots_json(data: list[dict]) -> None:
|
|
783
|
+
"""Render plots from a list of JSON-deserialized config dicts."""
|
|
784
|
+
for entry in data:
|
|
785
|
+
try:
|
|
786
|
+
Plotter(JsonParser.parse(entry)).plot()
|
|
787
|
+
except Exception as exc:
|
|
788
|
+
log.error("Failed to process JSON command: %s", exc)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def process_subplots(commands: list[str], layout: str, axd: dict) -> None:
|
|
792
|
+
"""Parse and render commands into a subplot mosaic layout."""
|
|
793
|
+
panel_keys = [ch for ch in layout if ch not in ("\n", " ")]
|
|
794
|
+
|
|
795
|
+
for idx, command in enumerate(commands):
|
|
796
|
+
if idx >= len(panel_keys):
|
|
797
|
+
log.warning("More commands than subplot panels — skipping extra.")
|
|
798
|
+
break
|
|
799
|
+
try:
|
|
800
|
+
cfg = CommandParser.parse(command)
|
|
801
|
+
key = panel_keys[idx]
|
|
802
|
+
log.info("Rendering into subplot panel '%s'.", key)
|
|
803
|
+
Plotter(cfg).subplot_mosaic(ax=axd[key])
|
|
804
|
+
except Exception as exc:
|
|
805
|
+
log.error("Failed subplot command %r: %s", command, exc)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def save_plot(command: str) -> None:
|
|
809
|
+
"""Save the figure to disk if --save <path> appears in *command*."""
|
|
810
|
+
if "--save" not in sys.argv:
|
|
811
|
+
return
|
|
812
|
+
m = re.search(r"--save (\S+)", command)
|
|
813
|
+
if m:
|
|
814
|
+
path = m.group(1)
|
|
815
|
+
plt.savefig(path)
|
|
816
|
+
log.info("Plot saved as '%s'.", path)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# ── JSON persistence ──────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
def _save_configs_to_json(configs: list[PlotConfig]) -> None:
|
|
822
|
+
serializable = [cfg.__dict__.copy() for cfg in configs]
|
|
823
|
+
with JSON_OUTPUT_PATH.open("w") as fh:
|
|
824
|
+
json.dump(serializable, fh, indent=4)
|
|
825
|
+
log.info("Plot config saved to '%s'.", JSON_OUTPUT_PATH)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ── Help utilities ─────────────────────────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
def _show_help(section: str = "all") -> None:
|
|
831
|
+
"""Import mfp_help and print the requested section."""
|
|
832
|
+
try:
|
|
833
|
+
from . import mfp_help
|
|
834
|
+
except Exception as e:
|
|
835
|
+
log.error(f"Failed to import mfp_help: {e}")
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
try:
|
|
839
|
+
mfp_help.show(section)
|
|
840
|
+
except Exception as e:
|
|
841
|
+
log.error(f"Help system error: {e}")
|
|
842
|
+
|
|
843
|
+
def _list_styles() -> None:
|
|
844
|
+
print("\n ── Line / marker styles ─────────────────────────────────────")
|
|
845
|
+
for k, v in STYLE_MAP.items():
|
|
846
|
+
print(f" {k:<20} → matplotlib '{v}'")
|
|
847
|
+
print("\n ── Error-bar styles ─────────────────────────────────────────")
|
|
848
|
+
for s in sorted(ERRORBAR_STYLES):
|
|
849
|
+
print(f" {s}")
|
|
850
|
+
print("\n ── Colormap scatter ─────────────────────────────────────────")
|
|
851
|
+
for s in sorted(COLORMAP_STYLES):
|
|
852
|
+
print(f" {s}")
|
|
853
|
+
print("\n ── 2-D / matrix styles ──────────────────────────────────────")
|
|
854
|
+
for s in sorted(TWO_D_STYLES):
|
|
855
|
+
print(f" {s}")
|
|
856
|
+
print("\n ── Seaborn distribution styles ──────────────────────────────")
|
|
857
|
+
for s in sorted(SEABORN_STYLES):
|
|
858
|
+
print(f" {s}")
|
|
859
|
+
print()
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
863
|
+
# Entry point
|
|
864
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
865
|
+
|
|
866
|
+
def _build_command_string() -> str:
|
|
867
|
+
return " ".join(sys.argv[1:])
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _split_commands(command_string: str) -> list[str]:
|
|
871
|
+
"""
|
|
872
|
+
Split commands by comma, respecting quoted strings.
|
|
873
|
+
Only splits on comma when followed by a known keyword/pattern (new command).
|
|
874
|
+
"""
|
|
875
|
+
# Known patterns that indicate a new command after comma
|
|
876
|
+
new_command_patterns = ('data', 'func', 'matrix', 'plot', 'file')
|
|
877
|
+
|
|
878
|
+
tokens = []
|
|
879
|
+
current = []
|
|
880
|
+
in_quotes = False
|
|
881
|
+
|
|
882
|
+
i = 0
|
|
883
|
+
while i < len(command_string):
|
|
884
|
+
char = command_string[i]
|
|
885
|
+
|
|
886
|
+
if char in ('"', "'"):
|
|
887
|
+
in_quotes = not in_quotes
|
|
888
|
+
current.append(char)
|
|
889
|
+
elif char == ',' and not in_quotes:
|
|
890
|
+
# Check what follows the comma
|
|
891
|
+
rest = command_string[i+1:].lstrip()
|
|
892
|
+
if rest and any(rest.startswith(p) for p in new_command_patterns):
|
|
893
|
+
# This is a new command separator
|
|
894
|
+
tokens.append(''.join(current).strip())
|
|
895
|
+
current = []
|
|
896
|
+
else:
|
|
897
|
+
# Part of a value - don't split
|
|
898
|
+
current.append(char)
|
|
899
|
+
else:
|
|
900
|
+
current.append(char)
|
|
901
|
+
|
|
902
|
+
i += 1
|
|
903
|
+
|
|
904
|
+
tokens.append(''.join(current).strip())
|
|
905
|
+
return [t for t in tokens if t]
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def main() -> None: # noqa: C901
|
|
909
|
+
"""Parse CLI arguments and dispatch to the appropriate rendering path."""
|
|
910
|
+
|
|
911
|
+
if len(sys.argv) < 2:
|
|
912
|
+
_show_help()
|
|
913
|
+
sys.exit(0)
|
|
914
|
+
|
|
915
|
+
subcommand = sys.argv[1]
|
|
916
|
+
|
|
917
|
+
# ── Help & info ───────────────────────────────────────────────────────────
|
|
918
|
+
if subcommand in {"--help", "-h", "help"}:
|
|
919
|
+
_show_help(sys.argv[2] if len(sys.argv) > 2 else "all")
|
|
920
|
+
return
|
|
921
|
+
|
|
922
|
+
if subcommand == "--list-styles":
|
|
923
|
+
_list_styles()
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
# ── Special subcommands ───────────────────────────────────────────────────
|
|
927
|
+
if subcommand == "plot.json":
|
|
928
|
+
log.info("Reading configuration from plot.json …")
|
|
929
|
+
with JSON_OUTPUT_PATH.open() as fh:
|
|
930
|
+
data = json.load(fh)
|
|
931
|
+
plt.figure(figsize=DEFAULT_FIGSIZE)
|
|
932
|
+
process_plots_json(data)
|
|
933
|
+
plt.tight_layout()
|
|
934
|
+
plt.show()
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
if subcommand == "forecast":
|
|
938
|
+
os.system(f"python3 {SCRIPT_DIR / 'prophet_pred.py'}") # noqa: S605
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
if subcommand == "DM":
|
|
942
|
+
os.system(f"python3 {SCRIPT_DIR / 'mfp_dmanp.py'}") # noqa: S605
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
# ── Standard plot / subplot path ──────────────────────────────────────────
|
|
946
|
+
command = _build_command_string()
|
|
947
|
+
|
|
948
|
+
# Strip global flags before splitting into per-dataset commands.
|
|
949
|
+
core_command = re.sub(
|
|
950
|
+
r"--(?:xlog|ylog|save \S+|subplot \S+|figsize \S+)\s*", "", command
|
|
951
|
+
).strip()
|
|
952
|
+
|
|
953
|
+
# Split commands by comma, respecting quoted strings
|
|
954
|
+
commands = _split_commands(core_command)
|
|
955
|
+
|
|
956
|
+
# Extract figsize from command if provided
|
|
957
|
+
figsize = DEFAULT_FIGSIZE
|
|
958
|
+
figsize_match = re.search(r"--figsize (\d+):(\d+)", command)
|
|
959
|
+
if figsize_match:
|
|
960
|
+
figsize = (int(figsize_match.group(1)), int(figsize_match.group(2)))
|
|
961
|
+
log.info("Figure size set to: %s", figsize)
|
|
962
|
+
|
|
963
|
+
axd = None
|
|
964
|
+
|
|
965
|
+
if "--subplot" in sys.argv:
|
|
966
|
+
m = re.search(r"--subplot (\S+)", command)
|
|
967
|
+
if not m:
|
|
968
|
+
log.error("--subplot flag found but no layout string provided.")
|
|
969
|
+
sys.exit(1)
|
|
970
|
+
layout = m.group(1)
|
|
971
|
+
if "-" in layout:
|
|
972
|
+
layout = "\n".join(row.strip() for row in layout.split("-"))
|
|
973
|
+
log.info("Subplot layout: %r", layout)
|
|
974
|
+
fig, axd = plt.subplot_mosaic(layout, figsize=figsize)
|
|
975
|
+
process_subplots(commands, layout, axd)
|
|
976
|
+
|
|
977
|
+
else:
|
|
978
|
+
plt.figure(figsize=figsize)
|
|
979
|
+
process_plots(commands)
|
|
980
|
+
|
|
981
|
+
# ── Post-draw global tweaks ───────────────────────────────────────────────
|
|
982
|
+
apply_log_scale(command, axd=axd)
|
|
983
|
+
plt.tight_layout()
|
|
984
|
+
save_plot(command)
|
|
985
|
+
plt.show()
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
if __name__ == "__main__":
|
|
989
|
+
main()
|