Implement matplotlib chart generator

This commit is contained in:
2026-01-29 17:59:01 +00:00
parent 3b136dca69
commit 50432eaa3d
2 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""Chart generation for reports."""
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
__all__ = ["ChartGenerator"]

View File

@@ -0,0 +1,232 @@
"""Chart generation using matplotlib.
This module provides chart generation for test reports using matplotlib.
Charts are rendered to base64-encoded PNG images for embedding in HTML/PDF.
"""
import base64
from io import BytesIO
import pandas as pd
from py_dvt_ate.data.models import TestResult, TestRun
from py_dvt_ate.reporting.exceptions import ChartGenerationError
class ChartGenerator:
"""Generates charts for test reports using matplotlib.
Charts are rendered with professional styling and returned as
base64-encoded PNG images suitable for embedding in HTML.
"""
def __init__(self, dpi: int = 150) -> None:
"""Initialise the chart generator.
Args:
dpi: Resolution for chart images (dots per inch).
"""
self.dpi = dpi
self._plt: type | None = None
def _get_matplotlib(self) -> tuple:
"""Lazy-load matplotlib to avoid import errors when not installed.
Returns:
Tuple of (pyplot module, matplotlib module).
Raises:
ChartGenerationError: If matplotlib is not installed.
"""
if self._plt is None:
try:
import matplotlib
matplotlib.use("Agg") # Non-interactive backend
import matplotlib.pyplot as plt
self._plt = (plt, matplotlib)
except ImportError as e:
msg = (
"matplotlib is required for chart generation. "
"Install it with: pip install py_dvt_ate[reports]"
)
raise ChartGenerationError(msg) from e
return self._plt
def _apply_style(self) -> None:
"""Apply professional styling to matplotlib charts."""
plt, _ = self._get_matplotlib()
plt.style.use("seaborn-v0_8-whitegrid")
plt.rcParams.update(
{
"font.family": "sans-serif",
"font.sans-serif": ["Helvetica", "Arial", "sans-serif"],
"font.size": 10,
"axes.titlesize": 12,
"axes.labelsize": 10,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"legend.fontsize": 9,
"figure.figsize": (8, 5),
"figure.dpi": self.dpi,
"axes.spines.top": False,
"axes.spines.right": False,
}
)
def _fig_to_base64(self, fig) -> str: # type: ignore[no-untyped-def]
"""Convert a matplotlib figure to base64-encoded PNG.
Args:
fig: Matplotlib figure object.
Returns:
Base64-encoded PNG image string.
"""
plt, _ = self._get_matplotlib()
buffer = BytesIO()
fig.savefig(buffer, format="png", dpi=self.dpi, bbox_inches="tight")
plt.close(fig)
buffer.seek(0)
return base64.b64encode(buffer.read()).decode("utf-8")
def generate_voltage_vs_temperature(
self, measurements: pd.DataFrame
) -> str:
"""Generate a voltage vs temperature chart.
Args:
measurements: DataFrame with 'temperature' and 'value' columns,
filtered to output voltage measurements.
Returns:
Base64-encoded PNG image string.
Raises:
ChartGenerationError: If chart generation fails.
"""
try:
plt, _ = self._get_matplotlib()
self._apply_style()
# Filter for output voltage measurements
voltage_data = measurements[
measurements["parameter"].str.contains("output_voltage", case=False)
].copy()
if voltage_data.empty:
msg = "No output voltage measurements found in data"
raise ChartGenerationError(msg)
# Group by temperature and get mean voltage at each point
grouped = voltage_data.groupby("temperature")["value"].mean().reset_index()
fig, ax = plt.subplots()
ax.plot(
grouped["temperature"],
grouped["value"],
marker="o",
linewidth=2,
markersize=6,
color="#2563eb",
)
ax.set_xlabel("Temperature (°C)")
ax.set_ylabel("Output Voltage (V)")
ax.set_title("Output Voltage vs Temperature")
ax.grid(True, alpha=0.3)
return self._fig_to_base64(fig)
except ChartGenerationError:
raise
except Exception as e:
msg = f"Failed to generate voltage vs temperature chart: {e}"
raise ChartGenerationError(msg) from e
def generate_results_bar_chart(self, results: list[TestResult]) -> str:
"""Generate a bar chart of test results.
Args:
results: List of test results.
Returns:
Base64-encoded PNG image string.
Raises:
ChartGenerationError: If chart generation fails.
"""
try:
plt, _ = self._get_matplotlib()
self._apply_style()
if not results:
msg = "No results to chart"
raise ChartGenerationError(msg)
# Prepare data
parameters = [r.parameter for r in results]
values = [r.value for r in results]
colours = ["#16a34a" if r.passed else "#dc2626" for r in results]
fig, ax = plt.subplots()
bars = ax.barh(parameters, values, color=colours)
# Add value labels
for bar, value in zip(bars, values, strict=False):
ax.text(
bar.get_width(),
bar.get_y() + bar.get_height() / 2,
f" {value:.4f}",
va="center",
fontsize=8,
)
ax.set_xlabel("Value")
ax.set_title("Test Results by Parameter")
ax.invert_yaxis()
return self._fig_to_base64(fig)
except ChartGenerationError:
raise
except Exception as e:
msg = f"Failed to generate results bar chart: {e}"
raise ChartGenerationError(msg) from e
def generate_all(
self,
run: TestRun,
results: list[TestResult],
measurements: pd.DataFrame | None,
) -> dict[str, str]:
"""Generate all applicable charts for a test run.
Args:
run: Test run metadata.
results: List of test results.
measurements: DataFrame of time-series measurements (optional).
Returns:
Dictionary mapping chart names to base64-encoded PNG images.
"""
charts: dict[str, str] = {}
# Try to generate voltage vs temperature chart if measurements available
if measurements is not None and not measurements.empty:
try:
charts["Voltage vs Temperature"] = self.generate_voltage_vs_temperature(
measurements
)
except ChartGenerationError:
pass # Skip if no voltage data
# Generate results bar chart if we have results
if results:
try:
charts["Results Summary"] = self.generate_results_bar_chart(results)
except ChartGenerationError:
pass # Skip if chart generation fails
return charts