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.
@@ -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()