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