diff --git a/src/py_dvt_ate/reporting/charts/__init__.py b/src/py_dvt_ate/reporting/charts/__init__.py new file mode 100644 index 0000000..d349c9d --- /dev/null +++ b/src/py_dvt_ate/reporting/charts/__init__.py @@ -0,0 +1,5 @@ +"""Chart generation for reports.""" + +from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator + +__all__ = ["ChartGenerator"] diff --git a/src/py_dvt_ate/reporting/charts/matplotlib_charts.py b/src/py_dvt_ate/reporting/charts/matplotlib_charts.py new file mode 100644 index 0000000..31cadd3 --- /dev/null +++ b/src/py_dvt_ate/reporting/charts/matplotlib_charts.py @@ -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