Implement matplotlib chart generator
This commit is contained in:
5
src/py_dvt_ate/reporting/charts/__init__.py
Normal file
5
src/py_dvt_ate/reporting/charts/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Chart generation for reports."""
|
||||
|
||||
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
|
||||
|
||||
__all__ = ["ChartGenerator"]
|
||||
232
src/py_dvt_ate/reporting/charts/matplotlib_charts.py
Normal file
232
src/py_dvt_ate/reporting/charts/matplotlib_charts.py
Normal 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
|
||||
Reference in New Issue
Block a user