dataframe-toolkit 0.4.1__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.
dftk/common/plot.py ADDED
@@ -0,0 +1,566 @@
1
+ """
2
+ dftk.common.plot — shared matplotlib infrastructure for dftk plot commands.
3
+
4
+ All plot commands (scat, line, hist) import from here for:
5
+ - Figure / axes creation with publication-quality defaults
6
+ - Font size presets (publication / screen / presentation)
7
+ - Figure size presets (single / double / full column, or explicit WxH)
8
+ - Colourblind-friendly default palette (Wong 2011)
9
+ - Shared argument groups for argparse
10
+ - Axis limit helpers
11
+ - Save-or-show helper
12
+
13
+ Font size hierarchy
14
+ -------------------
15
+ Each preset maps three semantic roles to point sizes:
16
+
17
+ small → tick label numbers on axes
18
+ medium → axis labels, legend text
19
+ large → figure title, legend title
20
+
21
+ Presets:
22
+
23
+ publication 6 / 8 / 9 pt (Nature / Science figure standards)
24
+ screen 9 / 11 / 13 pt (default — readable on screen)
25
+ presentation 12 / 14 / 16 pt (slides)
26
+
27
+ Figure size presets (width × auto-height at golden ratio)
28
+ ----------------------------------------------------------
29
+ single 3.50" × 2.16" Nature single column (89 mm)
30
+ double 7.20" × 4.45" Nature double column (183 mm)
31
+ full 7.20" × 4.45" alias for double
32
+ WxH explicit inches, e.g. "5x3.5"
33
+ default: 5x3.5
34
+
35
+ Colour palette
36
+ --------------
37
+ Wong (2011) colourblind-safe 8-colour palette, also print-safe in greyscale.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import logging
44
+ import math
45
+
46
+ import matplotlib
47
+ import matplotlib.ticker as ticker
48
+ import numpy as np
49
+ import pandas as pd
50
+
51
+ # matplotlib.pyplot is imported lazily (inside functions) so that
52
+ # matplotlib.use() can be called in execute() before pyplot initialises
53
+ # the backend. Never add "import matplotlib.pyplot as plt" at module level.
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Colourblind-safe palette — Wong (2011) Nature Methods
59
+ # ---------------------------------------------------------------------------
60
+
61
+ WONG_PALETTE = [
62
+ "#E69F00", # orange
63
+ "#56B4E9", # sky blue
64
+ "#009E73", # bluish green
65
+ "#F0E442", # yellow
66
+ "#0072B2", # blue
67
+ "#D55E00", # vermilion
68
+ "#CC79A7", # reddish purple
69
+ "#000000", # black
70
+ ]
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Font size presets
74
+ # ---------------------------------------------------------------------------
75
+
76
+ _FONT_PRESETS: dict[str, tuple[int, int, int]] = {
77
+ # small medium large
78
+ "publication": (6, 8, 9),
79
+ "screen": (9, 11, 13),
80
+ "presentation": (12, 14, 16),
81
+ }
82
+ _DEFAULT_FONTSIZE = "screen"
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Figure size presets (width_inches, height_inches)
86
+ # ---------------------------------------------------------------------------
87
+
88
+ _GOLDEN = (1 + math.sqrt(5)) / 2 # ≈ 1.618
89
+
90
+
91
+ def _auto_height(w: float) -> float:
92
+ return round(w / _GOLDEN, 2)
93
+
94
+
95
+ _SIZE_PRESETS: dict[str, tuple[float, float]] = {
96
+ "single": (3.50, _auto_height(3.50)),
97
+ "double": (7.20, _auto_height(7.20)),
98
+ "full": (7.20, _auto_height(7.20)),
99
+ }
100
+ _DEFAULT_SIZE = "10x6"
101
+
102
+
103
+ def parse_figsize(size_str: str) -> tuple[float, float]:
104
+ """Parse --size argument into (width, height) inches."""
105
+ if size_str in _SIZE_PRESETS:
106
+ return _SIZE_PRESETS[size_str]
107
+ try:
108
+ w_str, h_str = size_str.lower().split("x")
109
+ return float(w_str), float(h_str)
110
+ except (ValueError, AttributeError):
111
+ logger.warning("Cannot parse --size %r; using default 5x3.5", size_str)
112
+ return 5.0, 3.5
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # rcParams application
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def apply_style(args: argparse.Namespace) -> None:
121
+ """
122
+ Apply font sizes, palette, and optional user stylesheets to rcParams.
123
+ Call once before creating the figure.
124
+ """
125
+ import matplotlib.pyplot as plt
126
+
127
+ preset = getattr(args, "fontsize", _DEFAULT_FONTSIZE)
128
+ if preset not in _FONT_PRESETS:
129
+ logger.warning("Unknown --fontsize %r; using 'screen'", preset)
130
+ preset = "screen"
131
+
132
+ small, medium, large = _FONT_PRESETS[preset]
133
+
134
+ matplotlib.rcParams.update(
135
+ {
136
+ "font.family": "sans-serif",
137
+ "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
138
+ "xtick.labelsize": small,
139
+ "ytick.labelsize": small,
140
+ "axes.labelsize": medium,
141
+ "legend.fontsize": medium,
142
+ "axes.titlesize": large,
143
+ "legend.title_fontsize": large,
144
+ "figure.dpi": 100,
145
+ "savefig.dpi": 300,
146
+ "axes.prop_cycle": matplotlib.cycler(color=WONG_PALETTE),
147
+ }
148
+ )
149
+
150
+ # Optional user stylesheets (applied after our base to allow overrides)
151
+ styles = getattr(args, "styles", None) or []
152
+ valid = [s for s in styles if s in plt.style.available]
153
+ if valid:
154
+ plt.style.use(valid)
155
+ # Re-enforce tick visibility which some styles suppress
156
+ matplotlib.rcParams["xtick.bottom"] = True
157
+ matplotlib.rcParams["ytick.left"] = True
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Figure / axes creation
162
+ # ---------------------------------------------------------------------------
163
+
164
+ _MAX_SCREEN_W = 16.0 # inches — cap for interactive multi-panel figures
165
+ _MAX_SCREEN_H = 10.0
166
+
167
+
168
+ def make_figure(
169
+ args: argparse.Namespace,
170
+ nrows: int = 1,
171
+ ncols: int = 1,
172
+ ) -> tuple[matplotlib.figure.Figure, np.ndarray]:
173
+ """
174
+ Create a figure with nrows × ncols axes.
175
+
176
+ Returns (fig, axes) where axes is always a 2-D numpy array of shape
177
+ (nrows, ncols) for uniform indexing even when nrows=ncols=1.
178
+
179
+ For publication presets (single/double/full) the per-panel size is
180
+ respected exactly. For freeform sizes (WxH, including the default)
181
+ the total figure is capped at _MAX_SCREEN_W × _MAX_SCREEN_H so that
182
+ large subplot grids remain at a comfortable screen size.
183
+ """
184
+ import matplotlib.pyplot as plt
185
+
186
+ size_str = getattr(args, "size", _DEFAULT_SIZE)
187
+ w, h = parse_figsize(size_str)
188
+
189
+ w_total = w * ncols
190
+ h_total = h * nrows
191
+
192
+ # Cap total size for freeform (non-preset) sizes so grids don't balloon.
193
+ if size_str not in _SIZE_PRESETS:
194
+ w_total = min(w_total, _MAX_SCREEN_W)
195
+ h_total = min(h_total, _MAX_SCREEN_H)
196
+
197
+ fig, axes = plt.subplots(
198
+ nrows,
199
+ ncols,
200
+ figsize=(w_total, h_total),
201
+ squeeze=False,
202
+ )
203
+ return fig, axes
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Subplot grid for --subgraphcol
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def subgraph_layout(n_groups: int, ncols_hint: int | None = None) -> tuple[int, int]:
212
+ """Return (nrows, ncols) for a grid of n_groups subplots."""
213
+ if ncols_hint is not None:
214
+ ncols = max(1, ncols_hint)
215
+ else:
216
+ ncols = min(n_groups, 3) # default: at most 3 columns
217
+ nrows = math.ceil(n_groups / ncols)
218
+ return nrows, ncols
219
+
220
+
221
+ def subgraph_groups(
222
+ df: pd.DataFrame,
223
+ cols: list[str],
224
+ order: list[str] | None = None,
225
+ ) -> list[tuple]:
226
+ """Return list of (key, subdf) pairs from df.groupby(cols).
227
+
228
+ If *order* is given the panels are sorted to match it; values not listed
229
+ appear at the end in their original groupby order.
230
+ """
231
+ groups = list(df.groupby(cols))
232
+ if order:
233
+ rank = {v: i for i, v in enumerate(order)}
234
+ n = len(order)
235
+
236
+ def _rank(g):
237
+ key = g[0]
238
+ # groupby with a list always returns tuple keys; unwrap single-col case
239
+ if isinstance(key, tuple) and len(key) == 1:
240
+ key = key[0]
241
+ return rank.get(str(key), n)
242
+
243
+ groups.sort(key=_rank)
244
+ return groups
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Axis helpers
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def _to_float(s: str, fallback: float) -> float:
253
+ try:
254
+ return float(s)
255
+ except (TypeError, ValueError):
256
+ return fallback
257
+
258
+
259
+ def apply_limits(ax: matplotlib.axes.Axes, args: argparse.Namespace) -> None:
260
+ """Apply --xlim / --ylim / --logx / --logy / --xmargin / --ymargin."""
261
+ xlim = getattr(args, "xlim", None)
262
+ ylim = getattr(args, "ylim", None)
263
+ logx = getattr(args, "logx", False)
264
+ logy = getattr(args, "logy", False)
265
+ xmargin = getattr(args, "xmargin", False)
266
+ ymargin = getattr(args, "ymargin", False)
267
+
268
+ if logx:
269
+ ax.set_xscale("log")
270
+ if logy:
271
+ ax.set_yscale("log")
272
+
273
+ if xmargin:
274
+ xmin, xmax = ax.get_xlim()
275
+ r = xmax - xmin
276
+ ax.set_xlim(xmin - 0.01 * r, xmax + 0.01 * r)
277
+ if ymargin:
278
+ ymin, ymax = ax.get_ylim()
279
+ r = ymax - ymin
280
+ ax.set_ylim(ymin - 0.01 * r, ymax + 0.01 * r)
281
+
282
+ if xlim is not None:
283
+ xmin, xmax = ax.get_xlim()
284
+ ax.set_xlim(
285
+ _to_float(xlim[0], xmin),
286
+ _to_float(xlim[1], xmax),
287
+ )
288
+ if ylim is not None:
289
+ ymin, ymax = ax.get_ylim()
290
+ ax.set_ylim(
291
+ _to_float(ylim[0], ymin),
292
+ _to_float(ylim[1], ymax),
293
+ )
294
+
295
+
296
+ def apply_ticks(ax: matplotlib.axes.Axes, args: argparse.Namespace) -> None:
297
+ """Apply --xticks / --yticks major/minor locators."""
298
+ xticks = getattr(args, "xticks", None)
299
+ yticks = getattr(args, "yticks", None)
300
+ if xticks is not None:
301
+ ax.xaxis.set_major_locator(ticker.MultipleLocator(xticks[0]))
302
+ ax.xaxis.set_minor_locator(ticker.MultipleLocator(xticks[1]))
303
+ if yticks is not None:
304
+ ax.yaxis.set_major_locator(ticker.MultipleLocator(yticks[0]))
305
+ ax.yaxis.set_minor_locator(ticker.MultipleLocator(yticks[1]))
306
+
307
+
308
+ def apply_labels(
309
+ ax: matplotlib.axes.Axes,
310
+ args: argparse.Namespace,
311
+ default_xlabel: str = "",
312
+ default_ylabel: str = "",
313
+ ) -> None:
314
+ """Apply title, xlabel, ylabel, defaulting to column names."""
315
+ xlabel = getattr(args, "xlabel", None) or default_xlabel
316
+ ylabel = getattr(args, "ylabel", None) or default_ylabel
317
+ title = getattr(args, "title", "") or ""
318
+ ax.set_xlabel(xlabel)
319
+ ax.set_ylabel(ylabel)
320
+ ax.set_title(title)
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Group label
325
+ # ---------------------------------------------------------------------------
326
+
327
+
328
+ def make_grouplabel(
329
+ groupname,
330
+ groupcols: list[str] | None = None,
331
+ fmt: str | None = None,
332
+ ) -> str:
333
+ """Format a groupby key as a legend label or subplot title.
334
+
335
+ Default: value(s) only, joined by " " for multi-column groups.
336
+ With *fmt*: an f-string template where column names are the placeholders,
337
+ e.g. ``"{treatment} ({dose} mg)"`` for groupcols=["treatment", "dose"].
338
+ """
339
+ if isinstance(groupname, tuple):
340
+ vals = dict(zip(groupcols or [], groupname, strict=False)) if groupcols else {}
341
+ else:
342
+ vals = {groupcols[0]: groupname} if groupcols else {}
343
+
344
+ if fmt:
345
+ try:
346
+ return fmt.format(**vals)
347
+ except KeyError:
348
+ pass # fall through to default if template references unknown key
349
+
350
+ if isinstance(groupname, tuple):
351
+ return " ".join(str(v) for v in groupname)
352
+ return str(groupname)
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Save / show
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ def save_or_show(fig: matplotlib.figure.Figure, args: argparse.Namespace) -> None:
361
+ """Save figure to --file (300 dpi) or display interactively."""
362
+ import matplotlib.pyplot as plt
363
+
364
+ outfile = getattr(args, "file", None)
365
+ if outfile:
366
+ fig.savefig(outfile, bbox_inches="tight", dpi=300)
367
+ logger.info("Saved figure to %s", outfile)
368
+ else:
369
+ backend = matplotlib.get_backend().lower()
370
+ if backend in ("agg", "cairo", "pdf", "ps", "svg", "pgf"):
371
+ import sys
372
+
373
+ print(
374
+ f"Warning: no interactive display (backend: {backend}). "
375
+ "Use -f/--file to save the figure to a file.",
376
+ file=sys.stderr,
377
+ )
378
+ else:
379
+ plt.show()
380
+ plt.close(fig)
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Shared argparse argument groups
385
+ # ---------------------------------------------------------------------------
386
+
387
+
388
+ def add_xy_arguments(parser: argparse.ArgumentParser) -> None:
389
+ """Add x/y column, label, limit, log-scale, margin, tick args."""
390
+ g = parser.add_argument_group("axes")
391
+ g.add_argument("-x", "--xcol", metavar="COL", help="X-axis column.")
392
+ g.add_argument("-y", "--ycol", metavar="COL", help="Y-axis column.")
393
+ g.add_argument(
394
+ "-xl",
395
+ "--xlabel",
396
+ metavar="TEXT",
397
+ default=None,
398
+ help="X-axis label (default: column name).",
399
+ )
400
+ g.add_argument(
401
+ "-yl",
402
+ "--ylabel",
403
+ metavar="TEXT",
404
+ default=None,
405
+ help="Y-axis label (default: column name).",
406
+ )
407
+ g.add_argument(
408
+ "-xm",
409
+ "--xlim",
410
+ nargs=2,
411
+ metavar=("LO", "HI"),
412
+ default=None,
413
+ help="X-axis limits.",
414
+ )
415
+ g.add_argument(
416
+ "-ym",
417
+ "--ylim",
418
+ nargs=2,
419
+ metavar=("LO", "HI"),
420
+ default=None,
421
+ help="Y-axis limits.",
422
+ )
423
+ g.add_argument("-lx", "--logx", action="store_true", help="Logarithmic x axis.")
424
+ g.add_argument("-ly", "--logy", action="store_true", help="Logarithmic y axis.")
425
+ g.add_argument(
426
+ "--xmargin", action="store_true", help="Add 1 %% margin to x limits."
427
+ )
428
+ g.add_argument(
429
+ "--ymargin", action="store_true", help="Add 1 %% margin to y limits."
430
+ )
431
+ g.add_argument(
432
+ "-xt",
433
+ "--xticks",
434
+ nargs=2,
435
+ type=float,
436
+ metavar=("MAJOR", "MINOR"),
437
+ default=None,
438
+ help="X-axis major and minor tick spacing.",
439
+ )
440
+ g.add_argument(
441
+ "-yt",
442
+ "--yticks",
443
+ nargs=2,
444
+ type=float,
445
+ metavar=("MAJOR", "MINOR"),
446
+ default=None,
447
+ help="Y-axis major and minor tick spacing.",
448
+ )
449
+
450
+
451
+ def add_figure_arguments(parser: argparse.ArgumentParser) -> None:
452
+ """Add figure-level args: title, size, fontsize, styles, file."""
453
+ g = parser.add_argument_group("figure")
454
+ g.add_argument("-t", "--title", default="", metavar="TEXT", help="Figure title.")
455
+ g.add_argument(
456
+ "--size",
457
+ default=_DEFAULT_SIZE,
458
+ metavar="PRESET|WxH",
459
+ help='Figure size: single (3.5" col), double (7.2" col), or WxH '
460
+ f"in inches (default: {_DEFAULT_SIZE}).",
461
+ )
462
+ g.add_argument(
463
+ "--fontsize",
464
+ choices=list(_FONT_PRESETS),
465
+ default=_DEFAULT_FONTSIZE,
466
+ help="Font size preset (default: screen). "
467
+ "small=tick labels, medium=axis labels/legend, "
468
+ "large=title/legend title.",
469
+ )
470
+ g.add_argument(
471
+ "--styles",
472
+ nargs="*",
473
+ metavar="STYLE",
474
+ default=None,
475
+ help="Matplotlib stylesheets to apply (e.g. seaborn-v0_8).",
476
+ )
477
+ g.add_argument(
478
+ "-f",
479
+ "--file",
480
+ default=None,
481
+ metavar="PATH",
482
+ help="Save figure to file instead of displaying it. "
483
+ "Format inferred from extension (.png, .pdf, .svg, …).",
484
+ )
485
+ g.add_argument(
486
+ "--usetex",
487
+ action="store_true",
488
+ help="Render text with LaTeX (requires a TeX installation).",
489
+ )
490
+
491
+
492
+ def add_group_arguments(parser: argparse.ArgumentParser) -> None:
493
+ """Add --groupcol and --subgraphcol."""
494
+ g = parser.add_argument_group("grouping")
495
+ g.add_argument(
496
+ "-g",
497
+ "--groupcol",
498
+ nargs="+",
499
+ default=None,
500
+ metavar="COL",
501
+ help="Colour-code by this column (one series per value).",
502
+ )
503
+ g.add_argument(
504
+ "--groupcolorder",
505
+ nargs="+",
506
+ default=None,
507
+ metavar="VAL",
508
+ help="Series order for --groupcol (e.g. control treated …). "
509
+ "Values not listed appear last in their original order.",
510
+ )
511
+ g.add_argument(
512
+ "--subgraphcol",
513
+ nargs="+",
514
+ default=None,
515
+ metavar="COL",
516
+ help="Split into a subplot grid by this column.",
517
+ )
518
+ g.add_argument(
519
+ "--groupcolformat",
520
+ default=None,
521
+ metavar="TEMPLATE",
522
+ help="Legend label template for --groupcol. "
523
+ "Use {colname} placeholders, e.g. '{treatment} ({dose} mg)'.",
524
+ )
525
+ g.add_argument(
526
+ "--subgraphorder",
527
+ nargs="+",
528
+ default=None,
529
+ metavar="VAL",
530
+ help="Panel order for --subgraphcol (e.g. Jan Feb Mar …). "
531
+ "Values not listed appear last in their original order.",
532
+ )
533
+ g.add_argument(
534
+ "--subgraphformat",
535
+ default=None,
536
+ metavar="TEMPLATE",
537
+ help="Subplot title template for --subgraphcol. "
538
+ "Use {colname} placeholders, e.g. 'Month: {month}'.",
539
+ )
540
+ g.add_argument(
541
+ "--ncols",
542
+ type=int,
543
+ default=None,
544
+ metavar="N",
545
+ help="Number of columns in the subplot grid (default: min(groups, 3)).",
546
+ )
547
+
548
+
549
+ def add_legend_arguments(parser: argparse.ArgumentParser) -> None:
550
+ """Add --legend / --legendtitle / --legendloc."""
551
+ g = parser.add_argument_group("legend")
552
+ g.add_argument(
553
+ "--legend",
554
+ default=None,
555
+ metavar="TEXT",
556
+ help="Legend label (when not using --groupcol).",
557
+ )
558
+ g.add_argument("--legendtitle", default=None, metavar="TEXT", help="Legend title.")
559
+ g.add_argument(
560
+ "--legendloc",
561
+ nargs=2,
562
+ type=float,
563
+ metavar=("X", "Y"),
564
+ default=None,
565
+ help="Legend position as (x, y) in axes coordinates.",
566
+ )
dftk/common/seed.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ dftk.common.seed — random seed normalisation.
3
+
4
+ The dftk scripts accept seeds as either integers or arbitrary strings.
5
+ Strings are deterministically mapped to 32-bit unsigned integers via MD5
6
+ so that friendly names like ``"experiment-1"`` work as reproducible seeds.
7
+ """
8
+
9
+ import hashlib
10
+
11
+
12
+ def normalize_seed(seed: str | int | None) -> int | None:
13
+ """Return a 32-bit integer seed suitable for ``numpy.random.seed()``.
14
+
15
+ * ``None`` → ``None`` (numpy draws from system entropy)
16
+ * integer or digit str → the integer value
17
+ * any other string → MD5-hashed to a 32-bit unsigned integer
18
+ """
19
+ if seed is None:
20
+ return None
21
+ try:
22
+ return int(seed)
23
+ except (ValueError, TypeError):
24
+ encoded = str(seed).encode("utf-8")
25
+ return int(hashlib.md5(encoded).hexdigest(), 16) & ((1 << 32) - 1)