plotting.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. """Plot styling helpers for publication-quality static charts."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. import textwrap
  6. import unicodedata
  7. import warnings
  8. from pathlib import Path
  9. from typing import Iterable, Sequence
  10. def configure_plotting_backend():
  11. """Configure a non-interactive matplotlib backend and return plotting modules."""
  12. import matplotlib
  13. current_backend = matplotlib.get_backend().lower()
  14. if "agg" not in current_backend:
  15. matplotlib.use("Agg", force=True)
  16. logging.getLogger("matplotlib.category").setLevel(logging.WARNING)
  17. import matplotlib.pyplot as plt
  18. import seaborn as sns
  19. return plt, sns
  20. def get_plot_font_family() -> str:
  21. """Return the best available CJK-capable font family for the local machine."""
  22. configure_plotting_backend()
  23. from matplotlib import font_manager
  24. preferred_families = [
  25. "Microsoft YaHei",
  26. "Noto Sans SC",
  27. "SimHei",
  28. "SimSun",
  29. "Arial Unicode MS",
  30. "DejaVu Sans",
  31. ]
  32. available = {font.name for font in font_manager.fontManager.ttflist}
  33. for family in preferred_families:
  34. if family in available:
  35. return family
  36. return "DejaVu Sans"
  37. def apply_publication_style():
  38. """Apply a consistent scientific plotting style with Chinese-safe fonts."""
  39. plt, sns = configure_plotting_backend()
  40. font_family = get_plot_font_family()
  41. sns.set_theme(context="talk", style="whitegrid", palette="deep")
  42. plt.rcParams.update(
  43. {
  44. "figure.figsize": (10.5, 6.2),
  45. "figure.dpi": 140,
  46. # Keep layout control conservative here; save_figure() owns final save-time fallback.
  47. "figure.constrained_layout.use": False,
  48. "savefig.dpi": 300,
  49. "savefig.bbox": "tight",
  50. "savefig.facecolor": "white",
  51. "axes.facecolor": "#FAFAFA",
  52. "axes.edgecolor": "#2F2F2F",
  53. "axes.labelcolor": "#1F1F1F",
  54. "axes.titleweight": "bold",
  55. "axes.titlesize": 16,
  56. "axes.labelsize": 12,
  57. "axes.linewidth": 1.0,
  58. "axes.spines.top": False,
  59. "axes.spines.right": False,
  60. "grid.alpha": 0.18,
  61. "grid.linestyle": "--",
  62. "grid.linewidth": 0.8,
  63. "legend.frameon": False,
  64. "legend.fontsize": 10,
  65. "legend.title_fontsize": 11,
  66. "lines.linewidth": 2.2,
  67. "lines.markersize": 6,
  68. "xtick.color": "#333333",
  69. "ytick.color": "#333333",
  70. "xtick.labelsize": 10,
  71. "ytick.labelsize": 10,
  72. "font.family": "sans-serif",
  73. "font.sans-serif": [font_family, "Microsoft YaHei", "Noto Sans SC", "SimHei", "DejaVu Sans"],
  74. "axes.unicode_minus": False,
  75. }
  76. )
  77. return plt, sns
  78. def ensure_ascii_text(value: object, fallback: str = "label") -> str:
  79. """Convert labels to ASCII-only text when a fully ASCII figure is desired."""
  80. text = str(value).strip()
  81. normalized = unicodedata.normalize("NFKD", text)
  82. ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
  83. compact_text = " ".join(ascii_text.split()).strip()
  84. return compact_text or fallback
  85. def ensure_ascii_sequence(values: Iterable[object], prefix: str = "label") -> list[str]:
  86. """Convert a sequence of labels to ASCII-only strings."""
  87. converted: list[str] = []
  88. for index, value in enumerate(values, start=1):
  89. converted.append(ensure_ascii_text(value, fallback=f"{prefix}_{index}"))
  90. return converted
  91. def prepare_month_index(values: Sequence[object]):
  92. """Convert Chinese or ISO-like month labels to a stable datetime index when possible."""
  93. import pandas as pd
  94. normalized_values = []
  95. for value in values:
  96. text = str(value).strip()
  97. normalized_text = text.replace("年", "-").replace("月", "").replace("/", "-")
  98. match = re.fullmatch(r"(\d{4})-(\d{1,2})", normalized_text)
  99. if match:
  100. year = int(match.group(1))
  101. month = int(match.group(2))
  102. normalized_values.append(f"{year:04d}-{month:02d}-01")
  103. else:
  104. normalized_values.append(text)
  105. parsed = pd.to_datetime(normalized_values, errors="coerce", format="%Y-%m-%d")
  106. if getattr(parsed, "notna", None) is not None and parsed.notna().all():
  107. return parsed
  108. return list(values)
  109. def wrap_text(value: object, width: int = 16) -> str:
  110. """Wrap long text labels for cleaner legends and axis ticks."""
  111. text = str(value)
  112. if len(text) <= width:
  113. return text
  114. return "\n".join(textwrap.wrap(text, width=width, break_long_words=False, break_on_hyphens=False))
  115. def beautify_axes(
  116. ax,
  117. *,
  118. title: str | None = None,
  119. xlabel: str | None = None,
  120. ylabel: str | None = None,
  121. rotate_xticks: int = 25,
  122. wrap_xticks: bool = False,
  123. wrap_width: int = 14,
  124. legend: bool = True,
  125. ):
  126. """Apply consistent axis-level polish to reduce overlap and improve readability."""
  127. if title:
  128. ax.set_title(title, pad=14)
  129. if xlabel:
  130. ax.set_xlabel(xlabel, labelpad=10)
  131. if ylabel:
  132. ax.set_ylabel(ylabel, labelpad=10)
  133. if wrap_xticks:
  134. tick_labels = [wrap_text(label.get_text(), width=wrap_width) for label in ax.get_xticklabels()]
  135. ax.set_xticklabels(tick_labels)
  136. for label in ax.get_xticklabels():
  137. label.set_rotation(rotate_xticks)
  138. label.set_horizontalalignment("right" if rotate_xticks else "center")
  139. ax.tick_params(axis="x", pad=6)
  140. ax.tick_params(axis="y", pad=6)
  141. ax.margins(x=0.02)
  142. if legend and ax.get_legend() is not None:
  143. ax.legend(loc="best", frameon=False)
  144. return ax
  145. def _resolve_save_figure_args(*args):
  146. """Support the new single-argument API and a minimal backward-compatible path."""
  147. plt, _ = configure_plotting_backend()
  148. if len(args) == 1:
  149. return plt.gcf(), args[0]
  150. if len(args) == 2 and hasattr(args[0], "savefig"):
  151. return args[0], args[1]
  152. raise TypeError("save_figure() expects save_figure(output_path) as the standard API.")
  153. def _is_layout_conflict(exc: Exception) -> bool:
  154. message = str(exc).lower()
  155. keywords = (
  156. "layout engine",
  157. "tight_layout",
  158. "constrained_layout",
  159. "colorbar layout",
  160. )
  161. return any(keyword in message for keyword in keywords)
  162. def _attempt_figure_save(fig, destination: Path) -> None:
  163. fig.savefig(destination, dpi=300, bbox_inches="tight", facecolor="white")
  164. def save_figure(*args) -> Path:
  165. """Save the current figure defensively.
  166. Standard API:
  167. save_figure(output_path)
  168. A minimal backward-compatible path for save_figure(fig, output_path) is kept
  169. internally, but prompt/tooling should only expose the single-argument form.
  170. """
  171. fig, output_path = _resolve_save_figure_args(*args)
  172. destination = Path(output_path)
  173. destination.parent.mkdir(parents=True, exist_ok=True)
  174. with warnings.catch_warnings():
  175. warnings.filterwarnings("ignore", message=".*figure layout has changed to tight.*")
  176. try:
  177. _attempt_figure_save(fig, destination)
  178. except Exception as exc:
  179. if not _is_layout_conflict(exc):
  180. raise
  181. # Defensive fallback: disable layout engines and retry without throwing
  182. # the common matplotlib heatmap/colorbar conflict back to the agent.
  183. try:
  184. if hasattr(fig, "set_layout_engine"):
  185. fig.set_layout_engine(None)
  186. except Exception:
  187. pass
  188. try:
  189. if hasattr(fig, "set_constrained_layout"):
  190. fig.set_constrained_layout(False)
  191. except Exception:
  192. pass
  193. try:
  194. fig.subplots_adjust(left=0.08, right=0.98, top=0.92, bottom=0.12)
  195. except Exception:
  196. pass
  197. _attempt_figure_save(fig, destination)
  198. return destination