plotting.py 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. """Shared figure styling and output helpers.
  2. Centralising this keeps every analysis visually consistent (same palette, same
  3. grid weight, same file formats) and means an analysis body contains only its own
  4. logic, not boilerplate.
  5. """
  6. import json
  7. import math
  8. import pathlib as pl
  9. from typing import Any, Dict, List, Literal, Sequence, Tuple
  10. import matplotlib
  11. matplotlib.use("Agg") # headless: analysis runs in a background worker thread
  12. import matplotlib.pyplot as plt # noqa: E402
  13. from matplotlib.axes import Axes # noqa: E402
  14. from matplotlib.figure import Figure # noqa: E402
  15. #: Raster resolution for the .png copies.
  16. DPI = 150
  17. def style_axes(
  18. ax: Axes, *, grid_axis: Literal["both", "x", "y"] = "both"
  19. ) -> None:
  20. """Apply the shared axis style: recessive grid, no top/right spines."""
  21. ax.grid(True, axis=grid_axis, color="#000000", alpha=0.08, linewidth=0.7)
  22. for spine in ("top", "right"):
  23. ax.spines[spine].set_visible(False)
  24. def panel_grid(
  25. n_panels: int,
  26. *,
  27. n_cols: int = 2,
  28. panel_size: Tuple[float, float] = (5.0, 3.4),
  29. ) -> Tuple[Figure, List[Axes]]:
  30. """Create a grid of panels sized to the panel count.
  31. Returns the figure and a flat list of exactly ``n_panels`` axes; any surplus
  32. axes in the final row are removed so no empty frames are shown.
  33. """
  34. n_panels = max(1, n_panels)
  35. n_cols = max(1, min(n_cols, n_panels))
  36. n_rows = math.ceil(n_panels / n_cols)
  37. # constrained layout keeps axis labels from colliding with the titles of the
  38. # row beneath them once several panels are stacked.
  39. fig, axes = plt.subplots(
  40. n_rows,
  41. n_cols,
  42. figsize=(panel_size[0] * n_cols, panel_size[1] * n_rows),
  43. squeeze=False,
  44. layout="constrained",
  45. )
  46. flat = [ax for row in axes for ax in row]
  47. for extra in flat[n_panels:]:
  48. extra.remove()
  49. return fig, flat[:n_panels]
  50. def add_legend(fig: Figure, axes: Sequence[Axes], *, ncol: int = 4) -> None:
  51. """Attach one shared, de-duplicated legend below the figure.
  52. A legend is always present when more than one series is drawn, so identity is
  53. never carried by colour alone.
  54. """
  55. handles: List[Any] = []
  56. labels: List[str] = []
  57. for ax in axes:
  58. for handle, label in zip(*ax.get_legend_handles_labels()):
  59. if label not in labels:
  60. handles.append(handle)
  61. labels.append(label)
  62. if len(labels) < 2:
  63. return
  64. fig.legend(
  65. handles,
  66. labels,
  67. loc="outside lower center" if fig.get_layout_engine() else "lower center",
  68. ncol=min(ncol, len(labels)),
  69. frameon=False,
  70. )
  71. #: Figures and the numbers behind them live in separate subdirectories of the
  72. #: analysis output dir, so the plots can be browsed without wading through JSON.
  73. PLOTS_SUBDIR = "plots"
  74. DATA_SUBDIR = "data"
  75. def plots_dir(out_dir: pl.Path) -> pl.Path:
  76. """``<analysis>/plots`` -- every figure lands here."""
  77. path = out_dir / PLOTS_SUBDIR
  78. path.mkdir(parents=True, exist_ok=True)
  79. return path
  80. def data_dir(out_dir: pl.Path) -> pl.Path:
  81. """``<analysis>/data`` -- the numbers behind the figures."""
  82. path = out_dir / DATA_SUBDIR
  83. path.mkdir(parents=True, exist_ok=True)
  84. return path
  85. def save_figure(fig: Figure, out_dir: pl.Path, stem: str) -> pl.Path:
  86. """Write ``plots/stem.png`` (raster) and ``plots/stem.pdf``; close the figure."""
  87. target = plots_dir(out_dir)
  88. png_path = target / f"{stem}.png"
  89. fig.savefig(png_path, dpi=DPI, bbox_inches="tight")
  90. fig.savefig(target / f"{stem}.pdf", bbox_inches="tight")
  91. plt.close(fig)
  92. return png_path
  93. def save_json(payload: Dict[str, Any], out_dir: pl.Path, stem: str) -> pl.Path:
  94. """Persist the numbers behind a figure so plots can be rebuilt or reused."""
  95. path = data_dir(out_dir) / f"{stem}.json"
  96. path.write_text(json.dumps(payload, indent=2, default=_json_default))
  97. return path
  98. def _json_default(value: Any) -> Any:
  99. """Fallback encoder for numpy scalars/arrays."""
  100. if hasattr(value, "tolist"):
  101. return value.tolist()
  102. if hasattr(value, "item"):
  103. return value.item()
  104. return str(value)