Compare commits
58 Commits
v0.1.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
885196d933
|
|||
|
f4c34e4689
|
|||
|
aba2cabbbc
|
|||
|
1ec05ea289
|
|||
|
b826337b36
|
|||
|
235d668d9f
|
|||
|
13f93b6739
|
|||
|
bc15df3051
|
|||
|
66cdd4494c
|
|||
|
d1981a3342
|
|||
|
45a2d9a6e5
|
|||
|
51a479c61e
|
|||
|
a6ef649090
|
|||
|
45a8f11650
|
|||
|
7e8943ac57
|
|||
| ddf2c9439d | |||
| 9d6086a4e5 | |||
| cc5a8191b0 | |||
| b7663d5a31 | |||
| 6830b3158c | |||
| c016320b71 | |||
| a5a2cf2473 | |||
| 13a4fd16b3 | |||
| 349663b4e1 | |||
| 2b92865745 | |||
| 022223af76 | |||
| bff13cd616 | |||
| 59a5bc1124 | |||
| 32daff69be | |||
| d76e610070 | |||
| 50432eaa3d | |||
| 3b136dca69 | |||
| 5405ceec7f | |||
| 01d8295512 | |||
| 3a8e6becf1 | |||
| af3116a025 | |||
| f7f2839e65 | |||
| 5fdb1e6eaf | |||
| ca7655704e | |||
| ba2ab9d5d8 | |||
| 64be5dacbf | |||
| a28752fc5b | |||
| 5152f85c8e | |||
| bd0071e88f | |||
| 400f97e9fb | |||
| cae52c1fa8 | |||
| 7c89cebf0b | |||
| 5d185815d0 | |||
| 9cf42112a6 | |||
| ed5362e712 | |||
| d1170b7db7 | |||
| 42356efce2 | |||
| 3fdaba500d | |||
| a0d096512f | |||
| 1f42098b6e | |||
| 7093446783 | |||
| 22be547e47 | |||
| 825af0b3bd |
50
.dockerignore
Normal file
50
.dockerignore
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitea
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info
|
||||||
|
.eggs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests/
|
||||||
|
pytest.ini
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Development
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Data (will be created fresh in container)
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.parquet
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.gitea/
|
||||||
@@ -57,10 +57,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev,reports]"
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
run: pytest --cov=src/py_dvt_ate --cov-report=xml
|
run: pytest --cov=src/py_dvt_ate --cov-report=xml
|
||||||
@@ -72,7 +77,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Release
|
name: Release
|
||||||
needs: [lint, typecheck, test]
|
needs: [lint, typecheck, test]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -91,7 +96,22 @@ jobs:
|
|||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
run: |
|
||||||
with:
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
files: dist/*
|
VERSION=${TAG_NAME#v}
|
||||||
generate_release_notes: true
|
BODY=$(awk "/^## \[${VERSION}\]/{flag=1; next} /^## \\[/{flag=0} flag" CHANGELOG.md)
|
||||||
|
echo "Creating release ${TAG_NAME}"
|
||||||
|
RESPONSE=$(curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/json" -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"${TAG_NAME}\", \"body\": $(echo "$BODY" | jq -Rs .)}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
||||||
|
echo "Created release ID: ${RELEASE_ID}"
|
||||||
|
if [ "$RELEASE_ID" != "null" ] && [ -n "$RELEASE_ID" ]; then
|
||||||
|
for file in dist/*; do
|
||||||
|
echo "Uploading $(basename ${file})..."
|
||||||
|
curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -F "attachment=@${file}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename ${file})"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Failed to create release: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ logs/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker deployment configuration for public demo
|
||||||
|
- Dockerfile with Python 3.11-slim and WeasyPrint dependencies
|
||||||
|
- .dockerignore to exclude tests, docs, and development files
|
||||||
|
- deploy/ directory with docker-compose, nginx config, and Cloudflare Tunnel setup
|
||||||
|
- PDF Report Generation (Sprint 18)
|
||||||
|
- Professional PDF reports from test results with charts and styling
|
||||||
|
- `ReportGenerator` class orchestrating data gathering, chart generation, and PDF output
|
||||||
|
- `ReportConfig` for customising company name, logo, charts, and DPI
|
||||||
|
- HTML templates with Jinja2 for report structure
|
||||||
|
- CSS stylesheet optimised for A4 PDF output via WeasyPrint
|
||||||
|
- Matplotlib-based chart generation (voltage vs temperature, results summary)
|
||||||
|
- New CLI commands:
|
||||||
|
- `list-runs`: Display recent test runs with IDs
|
||||||
|
- `export-report`: Generate PDF report from run ID
|
||||||
|
- Dashboard PDF download button in Results Viewer
|
||||||
|
- Reporting configuration section in default.yaml
|
||||||
|
- Unit tests for models, HTML renderer, and chart generator
|
||||||
|
- Integration test for full report generation pipeline
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Added `matplotlib>=3.8` to reports optional dependencies
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-12-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Streamlit Dashboard Enhancement (Sprint 17)
|
||||||
|
- HAL-based instrument control (no direct physics access)
|
||||||
|
- Test execution page for running TempCo characterisation
|
||||||
|
- Results viewer page with filtering and historical data
|
||||||
|
- Form-based parameter controls preventing UI clunkiness
|
||||||
|
- Live simulation charts with auto-start
|
||||||
|
- End-to-end integration tests covering full workflow
|
||||||
|
- Updated README with installation and usage instructions
|
||||||
|
- Proprietary licence
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Integration tests now run 100x faster with simulation time scaling
|
||||||
|
- Removed confusing pause/clear chart buttons from dashboard
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CI release workflow now creates proper releases with changelog description
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Dashboard uses InstrumentFactory and InstrumentSet abstraction
|
||||||
|
- Embedded SimulationServer with threading synchronisation
|
||||||
|
- SQLite repository close() method for Windows file handle cleanup
|
||||||
|
- 259 unit tests, 12 integration tests all passing
|
||||||
|
- Coverage: 100% on core physics/instrument modules
|
||||||
|
|
||||||
## [0.1.0-beta.2] - 2025-12-03
|
## [0.1.0-beta.2] - 2025-12-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -128,7 +178,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
| Version | Date | Milestone |
|
| Version | Date | Milestone |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| 0.1.0 | TBD | MVP Complete |
|
| 0.1.0 | 2025-12-04 | MVP Complete |
|
||||||
| 0.1.0-beta.2 | 2025-12-03 | First DVT test runs |
|
| 0.1.0-beta.2 | 2025-12-03 | First DVT test runs |
|
||||||
| 0.1.0-beta.1 | 2025-12-02 | HAL complete |
|
| 0.1.0-beta.1 | 2025-12-02 | HAL complete |
|
||||||
| 0.1.0-alpha.3 | 2025-12-02 | Network ready |
|
| 0.1.0-alpha.3 | 2025-12-02 | Network ready |
|
||||||
|
|||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies for WeasyPrint (PDF generation)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src/ src/
|
||||||
|
COPY config/ config/
|
||||||
|
|
||||||
|
# Install package with reports dependencies (for PDF export)
|
||||||
|
RUN pip install --no-cache-dir -e ".[reports]"
|
||||||
|
|
||||||
|
# Create data directory for SQLite and measurements
|
||||||
|
RUN mkdir -p /app/data/measurements /app/data/reports
|
||||||
|
|
||||||
|
# Streamlit configuration
|
||||||
|
RUN mkdir -p ~/.streamlit
|
||||||
|
RUN echo '[server]\n\
|
||||||
|
headless = true\n\
|
||||||
|
address = "0.0.0.0"\n\
|
||||||
|
port = 8080\n\
|
||||||
|
enableXsrfProtection = false\n\
|
||||||
|
enableCORS = false\n\
|
||||||
|
\n\
|
||||||
|
[browser]\n\
|
||||||
|
gatherUsageStats = false\n' > ~/.streamlit/config.toml
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["streamlit", "run", "src/py_dvt_ate/app/dashboard/app.py"]
|
||||||
5
LICENSE
Normal file
5
LICENSE
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Copyright (c) 2025 Kai Chappell. All rights reserved.
|
||||||
|
|
||||||
|
This software is proprietary and confidential. Unauthorized copying,
|
||||||
|
distribution, modification, or use of this software, via any medium,
|
||||||
|
is strictly prohibited without prior written permission from the author.
|
||||||
79
README.md
79
README.md
@@ -1,12 +1,12 @@
|
|||||||
# py_dvt_ate
|
# py_dvt_ate
|
||||||
|
|
||||||
**ThermalATE: Coupled Physics DVT Simulation Platform**
|
**Coupled Physics DVT Simulation Platform**
|
||||||
|
|
||||||
A software simulation environment that accurately models the physical coupling between thermal and electrical domains, enabling DVT (Design Validation Test) engineers to develop, validate, and debug characterisation test sequences without physical access to laboratory equipment.
|
A software simulation environment for offline development of ATE (Automated Test Equipment) characterisation algorithms. Accurately models thermal-electrical coupling, enabling DVT engineers to develop and validate test sequences without physical laboratory access.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
ThermalATE enables offline development of ATE (Automated Test Equipment) characterisation algorithms by simulating:
|
py_dvt_ate simulates a complete DVT test bench:
|
||||||
|
|
||||||
- **Thermal Chamber** - Temperature control with realistic ramp and settling behaviour
|
- **Thermal Chamber** - Temperature control with realistic ramp and settling behaviour
|
||||||
- **Programmable Power Supply** - Voltage/current control and measurement
|
- **Programmable Power Supply** - Voltage/current control and measurement
|
||||||
@@ -29,11 +29,78 @@ ThermalATE enables offline development of ATE (Automated Test Equipment) charact
|
|||||||
| [Technical Specification](docs/02_technical_specification.md) | Specifies **how** to implement the system |
|
| [Technical Specification](docs/02_technical_specification.md) | Specifies **how** to implement the system |
|
||||||
| [Architecture Decisions](docs/03_architecture_decisions.md) | Explains **why** decisions were made |
|
| [Architecture Decisions](docs/03_architecture_decisions.md) | Explains **why** decisions were made |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with development dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Interactive Dashboard
|
||||||
|
|
||||||
|
Launch the Streamlit dashboard to visualise the physics simulation and run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
py-dvt-ate dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens a browser window with:
|
||||||
|
- **Live Simulation** - Real-time temperature/voltage charts with physics coupling
|
||||||
|
- **Test Execution** - Run TempCo characterisation tests
|
||||||
|
- **Results Viewer** - Browse and analyse historical test results
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the simulation server (TCP ports for SCPI instruments)
|
||||||
|
py-dvt-ate serve
|
||||||
|
|
||||||
|
# List available tests
|
||||||
|
py-dvt-ate tests list
|
||||||
|
|
||||||
|
# Run a TempCo test
|
||||||
|
py-dvt-ate tests run tempco --config config/tempco_test.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from py_dvt_ate.instruments import InstrumentFactory
|
||||||
|
from py_dvt_ate.simulation import SimulationServer
|
||||||
|
|
||||||
|
# Start simulation server
|
||||||
|
server = SimulationServer()
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
# Create instruments via HAL
|
||||||
|
factory = InstrumentFactory()
|
||||||
|
instruments = factory.create_from_config("config/default.yaml")
|
||||||
|
|
||||||
|
# Control instruments using standard interfaces
|
||||||
|
instruments.chamber.set_temperature(85.0)
|
||||||
|
instruments.psu.set_voltage(1, 5.0)
|
||||||
|
instruments.psu.enable_output(1, True)
|
||||||
|
voltage = instruments.dmm.measure_dc_voltage()
|
||||||
|
|
||||||
|
print(f"Output voltage: {voltage:.4f} V")
|
||||||
|
```
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Status:** In Development
|
**Status:** MVP Complete (v0.1.0)
|
||||||
|
|
||||||
This project is currently being developed. See the requirements document for the full scope and success criteria.
|
The core vertical slice is functional:
|
||||||
|
- Physics engine with thermal-electrical coupling
|
||||||
|
- Virtual instruments (chamber, PSU, DMM)
|
||||||
|
- Hardware Abstraction Layer
|
||||||
|
- SCPI-over-TCP server
|
||||||
|
- Test framework with TempCo test
|
||||||
|
- Streamlit dashboard
|
||||||
|
- SQLite/Parquet data persistence
|
||||||
|
|
||||||
|
See the requirements document for the full scope and future phases.
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
@@ -50,4 +117,4 @@ Kai Chappell
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
TBD
|
Proprietary - All rights reserved. See [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
@@ -147,3 +147,26 @@ api:
|
|||||||
|
|
||||||
# API server port
|
# API server port
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Report Generation Configuration
|
||||||
|
# =============================================================================
|
||||||
|
reporting:
|
||||||
|
# Company name displayed in report header
|
||||||
|
# This appears in the title block and footer of generated reports
|
||||||
|
company_name: py_dvt_ate
|
||||||
|
|
||||||
|
# Path to company logo image file (PNG or JPEG)
|
||||||
|
# If null, no logo is displayed in report header
|
||||||
|
# Example: ./assets/logo.png
|
||||||
|
logo_path: null
|
||||||
|
|
||||||
|
# Include charts in generated reports
|
||||||
|
# Charts show voltage vs temperature and results summary
|
||||||
|
# Set to false for text-only reports (smaller file size)
|
||||||
|
include_charts: true
|
||||||
|
|
||||||
|
# DPI (dots per inch) for chart images
|
||||||
|
# Higher values produce sharper charts but larger file sizes
|
||||||
|
# Recommended: 150 for screen viewing, 300 for print quality
|
||||||
|
chart_dpi: 150
|
||||||
|
|||||||
8
deploy/.env.example
Normal file
8
deploy/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Cloudflare Tunnel token
|
||||||
|
# Get this from: Cloudflare Zero Trust > Networks > Tunnels > Create > Docker
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token-here
|
||||||
|
|
||||||
|
# Idle auto-pause (seconds)
|
||||||
|
# Physics engine pauses after this many seconds with no viewers
|
||||||
|
# CPU drops to ~0% when paused, resumes instantly on visit
|
||||||
|
IDLE_PAUSE_SECONDS=30
|
||||||
19
deploy/docker-compose.yml
Normal file
19
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
streamlit:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: py-dvt-ate-streamlit
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- IDLE_PAUSE_SECONDS=${IDLE_PAUSE_SECONDS:-30} # Pause physics after 30s idle
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- public-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
public-network:
|
||||||
|
external: true
|
||||||
45
deploy/nginx.conf
Normal file
45
deploy/nginx.conf
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
upstream streamlit {
|
||||||
|
server streamlit:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Remove X-Frame-Options to allow iframe embedding
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
|
||||||
|
# Allow embedding from your domain
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self' https://kschappell.com https://*.kschappell.com";
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://streamlit;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket support (required for Streamlit)
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Standard proxy headers
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Timeouts for long-running connections
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
return 200 'ok';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
deploy/readme.md
Normal file
169
deploy/readme.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# py-dvt-ate Deployment
|
||||||
|
|
||||||
|
Deploy the DVT Simulation Platform dashboard with Cloudflare Tunnel for public access.
|
||||||
|
|
||||||
|
## Idle Auto-Pause
|
||||||
|
|
||||||
|
The physics simulation automatically pauses when no one is viewing the dashboard.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
# In .env or docker-compose.yml
|
||||||
|
IDLE_PAUSE_SECONDS=30 # Pause physics after 30s idle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behaviour:**
|
||||||
|
- When someone views the dashboard, physics runs normally
|
||||||
|
- After `IDLE_PAUSE_SECONDS` with no viewers, physics engine pauses
|
||||||
|
- CPU drops to ~0% while paused
|
||||||
|
- Physics resumes instantly when someone visits
|
||||||
|
- Container stays running (no restart needed)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
↓
|
||||||
|
Cloudflare Edge (dvt-demo.kschappell.com)
|
||||||
|
↓ (tunnel)
|
||||||
|
cloudflared container
|
||||||
|
↓
|
||||||
|
nginx container (WebSocket proxy + header handling)
|
||||||
|
↓
|
||||||
|
streamlit container (port 8080)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/fast-pool/apps/portfolio-demos/py-dvt-ate/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── nginx.conf
|
||||||
|
├── .env
|
||||||
|
└── data/ # Persistent storage (created automatically)
|
||||||
|
├── py_dvt_ate.db # SQLite database
|
||||||
|
├── measurements/ # Test measurement files
|
||||||
|
└── reports/ # Generated PDFs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Create Cloudflare Tunnel
|
||||||
|
|
||||||
|
1. Go to [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
|
||||||
|
2. Navigate to **Networks** → **Tunnels**
|
||||||
|
3. Click **Create a tunnel**
|
||||||
|
4. Select **Cloudflared** as the connector
|
||||||
|
5. Name it `py-dvt-ate` (or similar)
|
||||||
|
6. Copy the tunnel token (long string starting with `eyJ...`)
|
||||||
|
|
||||||
|
### 2. Configure Public Hostname
|
||||||
|
|
||||||
|
Still in the tunnel configuration:
|
||||||
|
|
||||||
|
1. Go to the **Public Hostname** tab
|
||||||
|
2. Add a public hostname:
|
||||||
|
- **Subdomain:** `dvt-demo`
|
||||||
|
- **Domain:** `kschappell.com`
|
||||||
|
- **Service Type:** `HTTP`
|
||||||
|
- **URL:** `nginx:80`
|
||||||
|
|
||||||
|
This routes `dvt-demo.kschappell.com` → nginx container → Streamlit app.
|
||||||
|
|
||||||
|
### 3. Deploy
|
||||||
|
|
||||||
|
The build requires access to the full py-dvt-ate source code. Two options:
|
||||||
|
|
||||||
|
**Option A: Clone repo to TrueNAS (recommended)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repo to apps directory
|
||||||
|
cd /mnt/fast-pool/apps/portfolio-demos
|
||||||
|
git clone https://gitea.kschappell.com/kschappell/py-dvt-ate.git
|
||||||
|
|
||||||
|
# Create data directory and .env
|
||||||
|
cd py-dvt-ate/deploy
|
||||||
|
mkdir -p data
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Add your CLOUDFLARE_TUNNEL_TOKEN
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Build image locally, transfer to TrueNAS**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On development machine
|
||||||
|
cd /path/to/py-dvt-ate
|
||||||
|
docker build -t py-dvt-ate:latest .
|
||||||
|
docker save py-dvt-ate:latest | gzip > py-dvt-ate.tar.gz
|
||||||
|
|
||||||
|
# Transfer to TrueNAS, then:
|
||||||
|
docker load < py-dvt-ate.tar.gz
|
||||||
|
|
||||||
|
# Update docker-compose.yml to use image instead of build:
|
||||||
|
# streamlit:
|
||||||
|
# image: py-dvt-ate:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check logs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check tunnel is connected (in Cloudflare dashboard, tunnel should show "Healthy")
|
||||||
|
|
||||||
|
# Test the endpoint
|
||||||
|
curl https://dvt-demo.kschappell.com/health
|
||||||
|
|
||||||
|
# Test iframe headers
|
||||||
|
curl -I https://dvt-demo.kschappell.com | grep -i frame
|
||||||
|
# Should NOT show X-Frame-Options (we strip it)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tunnel not connecting
|
||||||
|
|
||||||
|
Check cloudflared logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Invalid token (regenerate in Cloudflare dashboard)
|
||||||
|
- Network/firewall blocking outbound connections
|
||||||
|
|
||||||
|
### Streamlit not loading in iframe
|
||||||
|
|
||||||
|
Check nginx is stripping headers:
|
||||||
|
```bash
|
||||||
|
curl -I https://dvt-demo.kschappell.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Should see:
|
||||||
|
- No `X-Frame-Options` header
|
||||||
|
- `Content-Security-Policy: frame-ancestors 'self' https://kschappell.com ...`
|
||||||
|
|
||||||
|
### WebSocket errors
|
||||||
|
|
||||||
|
Check browser console for WebSocket connection failures. Ensure nginx WebSocket config is correct and timeouts are sufficient.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/fast-pool/apps/portfolio-demos/py-dvt-ate/deploy
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stopping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
460
docs/05_sprint_18_report_generation.md
Normal file
460
docs/05_sprint_18_report_generation.md
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
# Sprint 18: PDF Report Generation
|
||||||
|
|
||||||
|
| Document ID | DEV-002 |
|
||||||
|
|-------------|---------|
|
||||||
|
| Version | 1.0.0 |
|
||||||
|
| Status | Draft |
|
||||||
|
| Author | Kai Chappell |
|
||||||
|
| Created | 2026-01-29 |
|
||||||
|
| Last Updated | 2026-01-29 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines **Sprint 18** of py-dvt-ate development: automated PDF report generation from test results. Reports are designed to be professional and well-presented for recruiters/clients evaluating the simulation platform.
|
||||||
|
|
||||||
|
For project context, see:
|
||||||
|
- `01_requirements.md` - What the system must do
|
||||||
|
- `02_technical_specification.md` - How to implement
|
||||||
|
- `03_architecture_decisions.md` - Why decisions were made
|
||||||
|
- `04_development_plan.md` - Phase 1 MVP sprints (1-17)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
Add automated PDF report generation with:
|
||||||
|
- Professional, well-presented layout suitable for external stakeholders
|
||||||
|
- Clean UX with easy download from CLI and dashboard
|
||||||
|
- Test metadata, results table with pass/fail status, and measurement charts
|
||||||
|
- Configurable company branding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
Following existing project patterns:
|
||||||
|
|
||||||
|
1. **Small, Focused Commits** - Each task = 1 commit, ~50-150 lines changed
|
||||||
|
2. **Stubs First** - Define interfaces/types before implementation
|
||||||
|
3. **Test Alongside** - Write tests immediately after implementation
|
||||||
|
4. **UK English** - characterisation, behaviour, colour
|
||||||
|
5. **Minimal Context** - Each task completable with knowledge of 1-3 files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
### Task 18.1: Add reporting dependencies to pyproject.toml
|
||||||
|
|
||||||
|
- Add `matplotlib>=3.8` to reports optional dependency group
|
||||||
|
- Verify jinja2 and weasyprint already present
|
||||||
|
- **Files:** `pyproject.toml`
|
||||||
|
- **Commit:** "Add matplotlib to reports dependencies"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.2: Create report data models
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/models.py`
|
||||||
|
- Define `ReportConfig` dataclass:
|
||||||
|
- `company_name: str` - Company name for header
|
||||||
|
- `logo_path: Path | None` - Optional logo image path
|
||||||
|
- `include_charts: bool` - Whether to include charts
|
||||||
|
- `chart_dpi: int` - Chart resolution
|
||||||
|
- Define `ReportData` dataclass:
|
||||||
|
- `run: TestRun` - Test run metadata
|
||||||
|
- `results: list[TestResult]` - Scalar results with limits
|
||||||
|
- `measurements: pd.DataFrame | None` - Time-series data
|
||||||
|
- `charts: dict[str, str]` - Chart name to base64 PNG
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/models.py`
|
||||||
|
- **Commit:** "Add report data models"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.3: Create reporting exceptions
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/exceptions.py`
|
||||||
|
- Define exception hierarchy:
|
||||||
|
- `ReportingError` - Base exception
|
||||||
|
- `ReportGenerationError` - General generation failure
|
||||||
|
- `TemplateRenderError` - HTML rendering failure
|
||||||
|
- `PDFConversionError` - HTML to PDF conversion failure
|
||||||
|
- `ChartGenerationError` - Chart generation failure
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/exceptions.py`
|
||||||
|
- **Commit:** "Add reporting exception classes"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.4: Create CSS stylesheet for reports
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/templates/styles.css`
|
||||||
|
- Professional styling:
|
||||||
|
- A4 page setup with margins
|
||||||
|
- Header with company branding
|
||||||
|
- Footer with page numbers
|
||||||
|
- Data tables with borders
|
||||||
|
- Status badges (pass=green, fail=red, info=blue)
|
||||||
|
- Summary cards with colour coding
|
||||||
|
- Chart containers
|
||||||
|
- Print-optimised with page breaks
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/templates/styles.css`
|
||||||
|
- **Commit:** "Add professional CSS stylesheet for reports"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.5: Create base HTML template
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/templates/base.html`
|
||||||
|
- Jinja2 base template with:
|
||||||
|
- `<head>` with CSS include
|
||||||
|
- Header block with company name, logo, report metadata
|
||||||
|
- Content block (for child templates)
|
||||||
|
- Footer block with confidentiality notice, page numbers
|
||||||
|
- WeasyPrint `@page` rules for PDF pagination
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/templates/base.html`
|
||||||
|
- **Commit:** "Add base HTML report template"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.6: Create test report template
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/templates/test_report.html`
|
||||||
|
- Extends `base.html` with sections:
|
||||||
|
- **Test Overview**: name, description, status, timestamps, duration, operator
|
||||||
|
- **Results Summary**: total/pass/fail cards with counts
|
||||||
|
- **Results Table**: parameter, value, unit, limits, pass/fail badge
|
||||||
|
- **Charts**: voltage vs temperature (if available)
|
||||||
|
- **Configuration**: test config JSON (optional)
|
||||||
|
- Jinja2 filters for formatting (floats, dates)
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/templates/test_report.html`
|
||||||
|
- **Commit:** "Add test report HTML template"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.7: Implement HTML renderer
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/renderers/__init__.py`
|
||||||
|
- Create `src/py_dvt_ate/reporting/renderers/html.py`
|
||||||
|
- `HTMLRenderer` class:
|
||||||
|
- Constructor takes `ReportConfig`
|
||||||
|
- Uses `jinja2.Environment` with `PackageLoader`
|
||||||
|
- `render(report_data: ReportData) -> str` method
|
||||||
|
- Custom filters for number formatting
|
||||||
|
- Template loading from `py_dvt_ate.reporting.templates` package
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/renderers/html.py`, `src/py_dvt_ate/reporting/renderers/__init__.py`
|
||||||
|
- **Commit:** "Implement HTML renderer with Jinja2"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.8: Implement PDF renderer
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/renderers/pdf.py`
|
||||||
|
- `PDFRenderer` class:
|
||||||
|
- `render_to_file(html: str, output_path: Path) -> None`
|
||||||
|
- `render_to_bytes(html: str) -> bytes`
|
||||||
|
- Use WeasyPrint `HTML(string=html).write_pdf()`
|
||||||
|
- Handle WeasyPrint warnings gracefully
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/renderers/pdf.py`
|
||||||
|
- **Commit:** "Implement PDF renderer with WeasyPrint"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.9: Implement chart generator
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/charts/__init__.py`
|
||||||
|
- Create `src/py_dvt_ate/reporting/charts/matplotlib_charts.py`
|
||||||
|
- `ChartGenerator` class:
|
||||||
|
- Constructor takes `ReportConfig` (for DPI)
|
||||||
|
- `_setup_style()` - Configure matplotlib for professional appearance
|
||||||
|
- `generate_voltage_vs_temperature(measurements: DataFrame) -> str`
|
||||||
|
- Scatter plot with trend line
|
||||||
|
- Calculate and display slope (ppm/C)
|
||||||
|
- Return base64-encoded PNG
|
||||||
|
- `generate_all(run, results, measurements) -> dict[str, str]`
|
||||||
|
- Dispatch to appropriate chart methods based on test type
|
||||||
|
- Use `matplotlib.use('Agg')` for non-interactive backend
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/charts/matplotlib_charts.py`, `src/py_dvt_ate/reporting/charts/__init__.py`
|
||||||
|
- **Commit:** "Implement matplotlib chart generator"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.10: Implement ReportGenerator class
|
||||||
|
|
||||||
|
- Create `src/py_dvt_ate/reporting/generator.py`
|
||||||
|
- `IReportGenerator` Protocol:
|
||||||
|
- `generate(run_id: UUID, output_path: Path | None) -> Path`
|
||||||
|
- `generate_bytes(run_id: UUID) -> bytes`
|
||||||
|
- `ReportGenerator` class:
|
||||||
|
- Constructor: `repository`, `config`, `output_dir`
|
||||||
|
- Private: `_html_renderer`, `_pdf_renderer`, `_chart_generator`
|
||||||
|
- `_gather_data(run_id: UUID) -> ReportData`
|
||||||
|
- Fetch run, results, measurements from repository
|
||||||
|
- Generate charts if measurements available
|
||||||
|
- `_generate_output_path(run: TestRun) -> Path`
|
||||||
|
- Format: `{test_name}_{run_id_short}_{timestamp}.pdf`
|
||||||
|
- Error handling with appropriate exception types
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/generator.py`
|
||||||
|
- **Commit:** "Implement ReportGenerator class"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.11: Update reporting module exports
|
||||||
|
|
||||||
|
- Update `src/py_dvt_ate/reporting/__init__.py`
|
||||||
|
- Export public API:
|
||||||
|
- `ReportGenerator`, `IReportGenerator`
|
||||||
|
- `ReportConfig`, `ReportData`
|
||||||
|
- All exception classes
|
||||||
|
- Add module docstring with usage example
|
||||||
|
- Lazy imports to handle missing optional dependencies
|
||||||
|
- **Files:** `src/py_dvt_ate/reporting/__init__.py`
|
||||||
|
- **Commit:** "Update reporting module public API"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.12: Add ReportingConfig to app config
|
||||||
|
|
||||||
|
- Update `src/py_dvt_ate/app/config.py`
|
||||||
|
- Add `ReportingConfig` Pydantic model:
|
||||||
|
- `company_name: str = "DVT Engineering"`
|
||||||
|
- `logo_path: str | None = None`
|
||||||
|
- `include_charts: bool = True`
|
||||||
|
- `chart_dpi: int = 150`
|
||||||
|
- Add `reporting: ReportingConfig` to `AppConfig`
|
||||||
|
- **Files:** `src/py_dvt_ate/app/config.py`
|
||||||
|
- **Commit:** "Add ReportingConfig to application config"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.13: Add reporting section to default.yaml
|
||||||
|
|
||||||
|
- Update `config/default.yaml`
|
||||||
|
- Add `reporting:` section with all options
|
||||||
|
- Document each option with comments
|
||||||
|
- **Files:** `config/default.yaml`
|
||||||
|
- **Commit:** "Add reporting configuration to default.yaml"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.14: Add list-runs CLI command
|
||||||
|
|
||||||
|
- Update `src/py_dvt_ate/app/cli.py`
|
||||||
|
- Add `list-runs` command:
|
||||||
|
- `--limit` option (default 10)
|
||||||
|
- `--config` option for config file
|
||||||
|
- Output format: `{id:8} {test_name:15} {status:8} {timestamp}`
|
||||||
|
- Load repository from config
|
||||||
|
- **Files:** `src/py_dvt_ate/app/cli.py`
|
||||||
|
- **Commit:** "Add list-runs CLI command"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.15: Add export-report CLI command
|
||||||
|
|
||||||
|
- Update `src/py_dvt_ate/app/cli.py`
|
||||||
|
- Add `export-report` command:
|
||||||
|
- `run_id` argument (required)
|
||||||
|
- `--output` / `-o` option for output path
|
||||||
|
- `--company` option for company name override
|
||||||
|
- `--config` option for config file
|
||||||
|
- Support short (8-char) and full UUID lookup
|
||||||
|
- Display progress and result path
|
||||||
|
- **Files:** `src/py_dvt_ate/app/cli.py`
|
||||||
|
- **Commit:** "Add export-report CLI command"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.16: Add PDF download to dashboard
|
||||||
|
|
||||||
|
- Update `src/py_dvt_ate/app/dashboard/app.py`
|
||||||
|
- In results viewer page, add:
|
||||||
|
- "Generate PDF Report" button (primary)
|
||||||
|
- `st.download_button` for PDF download
|
||||||
|
- Progress spinner during generation
|
||||||
|
- Error handling for missing dependencies
|
||||||
|
- Store generated PDF in `st.session_state`
|
||||||
|
- **Files:** `src/py_dvt_ate/app/dashboard/app.py`
|
||||||
|
- **Commit:** "Add PDF download button to dashboard"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.17: Add reporting unit tests
|
||||||
|
|
||||||
|
- Create `tests/unit/reporting/__init__.py`
|
||||||
|
- Create `tests/unit/reporting/test_models.py`
|
||||||
|
- Test ReportConfig and ReportData creation
|
||||||
|
- Test default values
|
||||||
|
- Create `tests/unit/reporting/test_html_renderer.py`
|
||||||
|
- Test template rendering with mock data
|
||||||
|
- Test custom filters
|
||||||
|
- Create `tests/unit/reporting/test_chart_generator.py`
|
||||||
|
- Test chart generation produces valid base64
|
||||||
|
- Test with sample DataFrame
|
||||||
|
- **Files:** `tests/unit/reporting/`
|
||||||
|
- **Commit:** "Add reporting unit tests"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.18: Add reporting integration test
|
||||||
|
|
||||||
|
- Create `tests/integration/test_report_generation.py`
|
||||||
|
- End-to-end test:
|
||||||
|
- Create test run with sample results in repository
|
||||||
|
- Generate PDF report
|
||||||
|
- Verify PDF file created and non-empty
|
||||||
|
- Optionally verify PDF structure (page count)
|
||||||
|
- Use pytest fixtures for repository setup
|
||||||
|
- **Files:** `tests/integration/test_report_generation.py`
|
||||||
|
- **Commit:** "Add report generation integration test"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 18.19: Update CHANGELOG
|
||||||
|
|
||||||
|
- Update `CHANGELOG.md`
|
||||||
|
- Add `## [Unreleased]` section if not present
|
||||||
|
- Document:
|
||||||
|
- New `export-report` CLI command
|
||||||
|
- New `list-runs` CLI command
|
||||||
|
- Dashboard PDF download feature
|
||||||
|
- Reporting module with PDF/HTML generation
|
||||||
|
- **Files:** `CHANGELOG.md`
|
||||||
|
- **Commit:** "Update CHANGELOG with report generation feature"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure (New Files)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/py_dvt_ate/reporting/
|
||||||
|
├── __init__.py # Task 18.11 - Public API
|
||||||
|
├── models.py # Task 18.2 - ReportConfig, ReportData
|
||||||
|
├── exceptions.py # Task 18.3 - Exception hierarchy
|
||||||
|
├── generator.py # Task 18.10 - ReportGenerator
|
||||||
|
├── renderers/
|
||||||
|
│ ├── __init__.py # Task 18.7
|
||||||
|
│ ├── html.py # Task 18.7 - HTMLRenderer
|
||||||
|
│ └── pdf.py # Task 18.8 - PDFRenderer
|
||||||
|
├── charts/
|
||||||
|
│ ├── __init__.py # Task 18.9
|
||||||
|
│ └── matplotlib_charts.py # Task 18.9 - ChartGenerator
|
||||||
|
└── templates/
|
||||||
|
├── styles.css # Task 18.4 - CSS
|
||||||
|
├── base.html # Task 18.5 - Base template
|
||||||
|
└── test_report.html # Task 18.6 - Report template
|
||||||
|
|
||||||
|
tests/unit/reporting/
|
||||||
|
├── __init__.py # Task 18.17
|
||||||
|
├── test_models.py # Task 18.17
|
||||||
|
├── test_html_renderer.py # Task 18.17
|
||||||
|
└── test_chart_generator.py # Task 18.17
|
||||||
|
|
||||||
|
tests/integration/
|
||||||
|
└── test_report_generation.py # Task 18.18
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Tasks | Changes |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `pyproject.toml` | 18.1 | Add matplotlib to reports deps |
|
||||||
|
| `src/py_dvt_ate/app/config.py` | 18.12 | Add ReportingConfig |
|
||||||
|
| `config/default.yaml` | 18.13 | Add reporting section |
|
||||||
|
| `src/py_dvt_ate/app/cli.py` | 18.14, 18.15 | Add list-runs, export-report |
|
||||||
|
| `src/py_dvt_ate/app/dashboard/app.py` | 18.16 | Add PDF download |
|
||||||
|
| `CHANGELOG.md` | 18.19 | Document new features |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| jinja2 | >=3.1 | HTML template rendering |
|
||||||
|
| weasyprint | >=60.0 | HTML to PDF conversion |
|
||||||
|
| matplotlib | >=3.8 | Chart generation |
|
||||||
|
|
||||||
|
All in `[project.optional-dependencies] reports` group.
|
||||||
|
|
||||||
|
Install with: `pip install py-dvt-ate[reports]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### CLI Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List recent test runs
|
||||||
|
py-dvt-ate list-runs
|
||||||
|
|
||||||
|
# Generate PDF report
|
||||||
|
py-dvt-ate export-report <run_id>
|
||||||
|
|
||||||
|
# With options
|
||||||
|
py-dvt-ate export-report <run_id> -o ./my_report.pdf --company "Acme Corp"
|
||||||
|
|
||||||
|
# View generated PDF
|
||||||
|
xdg-open ./data/reports/*.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dashboard
|
||||||
|
streamlit run src/py_dvt_ate/app/dashboard/app.py
|
||||||
|
|
||||||
|
# In browser:
|
||||||
|
# 1. Navigate to Results Viewer
|
||||||
|
# 2. Select a test run
|
||||||
|
# 3. Click "Generate PDF Report"
|
||||||
|
# 4. Click "Download PDF Report"
|
||||||
|
# 5. Open downloaded PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests
|
||||||
|
pytest tests/unit/reporting/ -v
|
||||||
|
|
||||||
|
# Run integration test
|
||||||
|
pytest tests/integration/test_report_generation.py -v
|
||||||
|
|
||||||
|
# Check coverage
|
||||||
|
pytest tests/unit/reporting/ --cov=py_dvt_ate.reporting --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Progress
|
||||||
|
|
||||||
|
| Task | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| 18.1 | pending | Add matplotlib dependency |
|
||||||
|
| 18.2 | pending | Report data models |
|
||||||
|
| 18.3 | pending | Reporting exceptions |
|
||||||
|
| 18.4 | pending | CSS stylesheet |
|
||||||
|
| 18.5 | pending | Base HTML template |
|
||||||
|
| 18.6 | pending | Test report template |
|
||||||
|
| 18.7 | pending | HTML renderer |
|
||||||
|
| 18.8 | pending | PDF renderer |
|
||||||
|
| 18.9 | pending | Chart generator |
|
||||||
|
| 18.10 | pending | ReportGenerator class |
|
||||||
|
| 18.11 | pending | Module exports |
|
||||||
|
| 18.12 | pending | App config update |
|
||||||
|
| 18.13 | pending | default.yaml update |
|
||||||
|
| 18.14 | pending | list-runs CLI |
|
||||||
|
| 18.15 | pending | export-report CLI |
|
||||||
|
| 18.16 | pending | Dashboard download |
|
||||||
|
| 18.17 | pending | Unit tests |
|
||||||
|
| 18.18 | pending | Integration test |
|
||||||
|
| 18.19 | pending | CHANGELOG update |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Sprint 18 Plan**
|
||||||
@@ -30,6 +30,7 @@ api = [
|
|||||||
reports = [
|
reports = [
|
||||||
"jinja2>=3.1",
|
"jinja2>=3.1",
|
||||||
"weasyprint>=60.0",
|
"weasyprint>=60.0",
|
||||||
|
"matplotlib>=3.8",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
@@ -38,6 +39,7 @@ dev = [
|
|||||||
"ruff>=0.1",
|
"ruff>=0.1",
|
||||||
"mypy>=1.0",
|
"mypy>=1.0",
|
||||||
"types-PyYAML>=6.0",
|
"types-PyYAML>=6.0",
|
||||||
|
"pandas-stubs>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -82,6 +84,8 @@ disallow_incomplete_defs = true
|
|||||||
module = [
|
module = [
|
||||||
"streamlit.*",
|
"streamlit.*",
|
||||||
"plotly.*",
|
"plotly.*",
|
||||||
|
"weasyprint.*",
|
||||||
|
"matplotlib.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
|
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
|
||||||
|
|
||||||
__version__ = "0.1.0-beta.2"
|
__version__ = "0.1.0"
|
||||||
|
|||||||
@@ -83,5 +83,285 @@ def serve(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="list-tests")
|
||||||
|
def list_tests_cmd() -> None:
|
||||||
|
"""List all available DVT tests."""
|
||||||
|
from py_dvt_ate.app.test_commands import list_tests
|
||||||
|
|
||||||
|
list_tests()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="run-test")
|
||||||
|
def run_test_cmd(
|
||||||
|
test_name: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="Name of the test to run (use list-tests to see available tests)."),
|
||||||
|
],
|
||||||
|
config_file: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--config", "-c", help="Path to configuration YAML file."),
|
||||||
|
] = None,
|
||||||
|
operator: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--operator", "-o", help="Operator identifier (e.g., email address)."),
|
||||||
|
] = None,
|
||||||
|
description: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--description", "-d", help="Test run description."),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a specific DVT test.
|
||||||
|
|
||||||
|
The test will connect to instruments based on the configuration file
|
||||||
|
(default: config/default.yaml). Results are stored in the data directory.
|
||||||
|
"""
|
||||||
|
from py_dvt_ate.app.test_commands import run_test
|
||||||
|
|
||||||
|
run_test(
|
||||||
|
test_name=test_name,
|
||||||
|
config_file=config_file,
|
||||||
|
operator=operator,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="list-runs")
|
||||||
|
def list_runs_cmd(
|
||||||
|
config_file: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--config", "-c", help="Path to configuration YAML file."),
|
||||||
|
] = None,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
typer.Option("--limit", "-n", help="Maximum number of runs to display."),
|
||||||
|
] = 20,
|
||||||
|
) -> None:
|
||||||
|
"""List recent test runs with their IDs.
|
||||||
|
|
||||||
|
Shows a table of recent test runs including the short ID (for use with
|
||||||
|
export-report), test name, status, and timestamp.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from py_dvt_ate.app.config import load_config
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
if config_file is None:
|
||||||
|
config_path = Path("config/default.yaml")
|
||||||
|
if config_path.exists():
|
||||||
|
config_file = str(config_path)
|
||||||
|
|
||||||
|
config = load_config(config_file)
|
||||||
|
|
||||||
|
# Create repository
|
||||||
|
repo = SQLiteRepository(
|
||||||
|
db_path=config.data.database_path,
|
||||||
|
measurements_dir=config.data.measurements_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
runs = repo.get_all_runs()
|
||||||
|
|
||||||
|
if not runs:
|
||||||
|
console.print("[yellow]No test runs found.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
runs = runs[:limit]
|
||||||
|
|
||||||
|
# Create table
|
||||||
|
table = Table(title="Recent Test Runs")
|
||||||
|
table.add_column("ID", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("Test Name", style="white")
|
||||||
|
table.add_column("Status", style="white")
|
||||||
|
table.add_column("Started", style="dim")
|
||||||
|
|
||||||
|
for run in runs:
|
||||||
|
# Format status with colour
|
||||||
|
status = run.status.value.upper()
|
||||||
|
if status == "PASSED":
|
||||||
|
status_styled = f"[green]{status}[/green]"
|
||||||
|
elif status == "FAILED":
|
||||||
|
status_styled = f"[red]{status}[/red]"
|
||||||
|
elif status == "ERROR":
|
||||||
|
status_styled = f"[yellow]{status}[/yellow]"
|
||||||
|
else:
|
||||||
|
status_styled = status
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
run.id[:8],
|
||||||
|
run.test_name,
|
||||||
|
status_styled,
|
||||||
|
run.started_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
console.print(f"\n[dim]Showing {len(runs)} of {len(repo.get_all_runs())} runs[/dim]")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="export-report")
|
||||||
|
def export_report_cmd(
|
||||||
|
run_id: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="Test run ID (short 8-char or full UUID)."),
|
||||||
|
],
|
||||||
|
output: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--output", "-o", help="Output PDF file path."),
|
||||||
|
] = None,
|
||||||
|
company: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--company", help="Company name for report header."),
|
||||||
|
] = None,
|
||||||
|
config_file: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--config", "-c", help="Path to configuration YAML file."),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Export a PDF report for a test run.
|
||||||
|
|
||||||
|
Generate a professional PDF report from test results. The run_id can be
|
||||||
|
the short 8-character ID shown by list-runs, or the full UUID.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
py-dvt-ate export-report abc12345
|
||||||
|
py-dvt-ate export-report abc12345 --output ./my_report.pdf
|
||||||
|
py-dvt-ate export-report abc12345 --company "My Company"
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from py_dvt_ate.app.config import load_config
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
# Check for reporting dependencies
|
||||||
|
try:
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
except ImportError:
|
||||||
|
console.print(
|
||||||
|
"[red]Error:[/red] Report generation requires additional dependencies.\n"
|
||||||
|
"Install with: [cyan]pip install py_dvt_ate[reports][/cyan]"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
if config_file is None:
|
||||||
|
config_path = Path("config/default.yaml")
|
||||||
|
if config_path.exists():
|
||||||
|
config_file = str(config_path)
|
||||||
|
|
||||||
|
config = load_config(config_file)
|
||||||
|
|
||||||
|
# Create repository
|
||||||
|
repo = SQLiteRepository(
|
||||||
|
db_path=config.data.database_path,
|
||||||
|
measurements_dir=config.data.measurements_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Resolve short ID to full UUID
|
||||||
|
full_run_id: UUID | None = None
|
||||||
|
|
||||||
|
if len(run_id) == 8:
|
||||||
|
# Short ID - need to find full UUID
|
||||||
|
all_runs = repo.get_all_runs()
|
||||||
|
matching_runs = [r for r in all_runs if r.id.startswith(run_id)]
|
||||||
|
|
||||||
|
if not matching_runs:
|
||||||
|
console.print(f"[red]Error:[/red] No test run found with ID starting with '{run_id}'")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
elif len(matching_runs) > 1:
|
||||||
|
console.print(f"[red]Error:[/red] Multiple runs match '{run_id}'. Use full UUID.")
|
||||||
|
for run in matching_runs:
|
||||||
|
console.print(f" - {run.id} ({run.test_name})")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
full_run_id = UUID(matching_runs[0].id)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
full_run_id = UUID(run_id)
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[red]Error:[/red] Invalid run ID: '{run_id}'")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
|
||||||
|
# Create report config
|
||||||
|
report_config = ReportConfig(
|
||||||
|
company_name=company or config.reporting.company_name,
|
||||||
|
logo_path=Path(config.reporting.logo_path) if config.reporting.logo_path else None,
|
||||||
|
include_charts=config.reporting.include_charts,
|
||||||
|
chart_dpi=config.reporting.chart_dpi,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create generator
|
||||||
|
generator = ReportGenerator(
|
||||||
|
repository=repo,
|
||||||
|
config=report_config,
|
||||||
|
reports_dir=Path(config.data.reports_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate report
|
||||||
|
console.print(f"[cyan]Generating report for run {str(full_run_id)[:8]}...[/cyan]")
|
||||||
|
|
||||||
|
output_path = Path(output) if output else None
|
||||||
|
pdf_path = generator.generate(full_run_id, output_path)
|
||||||
|
|
||||||
|
console.print(f"[green]Report saved to:[/green] {pdf_path}")
|
||||||
|
|
||||||
|
except typer.Exit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error generating report:[/red] {e}")
|
||||||
|
raise typer.Exit(1) from None
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="query")
|
||||||
|
def query_cmd(
|
||||||
|
instrument: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="Instrument to query (chamber, psu, or dmm)."),
|
||||||
|
],
|
||||||
|
command: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="SCPI command to send (e.g., *IDN?, TEMP:SETPOINT?)."),
|
||||||
|
],
|
||||||
|
config_file: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--config", "-c", help="Path to configuration YAML file."),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a SCPI command to an instrument and print the response.
|
||||||
|
|
||||||
|
Useful for debugging and manual instrument control. Connect to
|
||||||
|
instruments based on configuration and send raw SCPI commands.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
py-dvt-ate query chamber "*IDN?"
|
||||||
|
py-dvt-ate query psu "VOLT? 1"
|
||||||
|
py-dvt-ate query dmm "MEAS:VOLT:DC?"
|
||||||
|
"""
|
||||||
|
from py_dvt_ate.app.instrument_commands import query_instrument
|
||||||
|
|
||||||
|
query_instrument(
|
||||||
|
instrument=instrument,
|
||||||
|
command=command,
|
||||||
|
config_file=config_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ class APIConfig(BaseModel):
|
|||||||
port: int = 8000
|
port: int = 8000
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingConfig(BaseModel):
|
||||||
|
"""PDF report generation configuration."""
|
||||||
|
|
||||||
|
company_name: str = "py_dvt_ate"
|
||||||
|
logo_path: str | None = None
|
||||||
|
include_charts: bool = True
|
||||||
|
chart_dpi: int = 150
|
||||||
|
|
||||||
|
|
||||||
class AppConfig(BaseModel):
|
class AppConfig(BaseModel):
|
||||||
"""Root configuration model."""
|
"""Root configuration model."""
|
||||||
|
|
||||||
@@ -120,6 +129,7 @@ class AppConfig(BaseModel):
|
|||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
||||||
api: APIConfig = Field(default_factory=APIConfig)
|
api: APIConfig = Field(default_factory=APIConfig)
|
||||||
|
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
|
||||||
|
|
||||||
|
|
||||||
def _apply_env_overrides(config_dict: dict[str, Any]) -> None:
|
def _apply_env_overrides(config_dict: dict[str, Any]) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
68
src/py_dvt_ate/app/instrument_commands.py
Normal file
68
src/py_dvt_ate/app/instrument_commands.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Instrument query commands for CLI."""
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from py_dvt_ate.app.config import load_config
|
||||||
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
|
|
||||||
|
|
||||||
|
def query_instrument(
|
||||||
|
instrument: str,
|
||||||
|
command: str,
|
||||||
|
config_file: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a SCPI command to an instrument and print the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instrument: Instrument to query (chamber, psu, or dmm).
|
||||||
|
command: SCPI command to send.
|
||||||
|
config_file: Path to configuration YAML file.
|
||||||
|
"""
|
||||||
|
# Load configuration
|
||||||
|
config_path = config_file or "config/default.yaml"
|
||||||
|
try:
|
||||||
|
config = load_config(config_path)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
typer.echo(f"Error: Configuration file not found: {config_path}", err=True)
|
||||||
|
raise typer.Exit(code=1) from err
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error loading configuration: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create instruments
|
||||||
|
try:
|
||||||
|
# Convert AppConfig to InstrumentConfig
|
||||||
|
inst_config = InstrumentConfig(
|
||||||
|
backend=config.instruments.backend,
|
||||||
|
simulator_host=config.instruments.simulator.host,
|
||||||
|
chamber_port=config.instruments.simulator.thermal_chamber_port,
|
||||||
|
psu_port=config.instruments.simulator.power_supply_port,
|
||||||
|
dmm_port=config.instruments.simulator.multimeter_port,
|
||||||
|
chamber_visa=config.instruments.pyvisa.thermal_chamber,
|
||||||
|
psu_visa=config.instruments.pyvisa.power_supply,
|
||||||
|
dmm_visa=config.instruments.pyvisa.multimeter,
|
||||||
|
)
|
||||||
|
instruments = InstrumentFactory.create(inst_config)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error connecting to instruments: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Send command to the specified instrument
|
||||||
|
try:
|
||||||
|
# Access the transport layer to send raw commands
|
||||||
|
if instrument == "chamber":
|
||||||
|
response = instruments.chamber._transport.query(command) # type: ignore[attr-defined]
|
||||||
|
elif instrument == "psu":
|
||||||
|
response = instruments.psu._transport.query(command) # type: ignore[attr-defined]
|
||||||
|
elif instrument == "dmm":
|
||||||
|
response = instruments.dmm._transport.query(command) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
typer.echo(f"Error: Unknown instrument '{instrument}'", err=True)
|
||||||
|
typer.echo("Valid instruments: chamber, psu, dmm", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
typer.echo(response)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error sending command: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
175
src/py_dvt_ate/app/test_commands.py
Normal file
175
src/py_dvt_ate/app/test_commands.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Test execution commands for CLI."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from py_dvt_ate.app.config import load_config
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
from py_dvt_ate.framework.context import ITest
|
||||||
|
from py_dvt_ate.framework.runner import TestRunner
|
||||||
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_tests() -> dict[str, type]:
|
||||||
|
"""Discover all available tests by scanning the tests package.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping test names to test classes.
|
||||||
|
"""
|
||||||
|
tests: dict[str, type] = {}
|
||||||
|
|
||||||
|
# Find the tests package directory
|
||||||
|
import py_dvt_ate.tests
|
||||||
|
|
||||||
|
tests_pkg_path = Path(py_dvt_ate.tests.__file__).parent
|
||||||
|
|
||||||
|
# Scan all Python files in the tests package
|
||||||
|
for py_file in tests_pkg_path.rglob("*.py"):
|
||||||
|
if py_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert file path to module name
|
||||||
|
rel_path = py_file.relative_to(tests_pkg_path.parent)
|
||||||
|
module_name = "py_dvt_ate." + str(rel_path.with_suffix("")).replace("/", ".").replace("\\", ".")
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
|
||||||
|
# Find all classes that implement ITest
|
||||||
|
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
||||||
|
if (
|
||||||
|
obj is not ITest
|
||||||
|
and issubclass(obj, ITest)
|
||||||
|
and not inspect.isabstract(obj)
|
||||||
|
and hasattr(obj, "name")
|
||||||
|
):
|
||||||
|
# Create instance to get the name property
|
||||||
|
instance = obj()
|
||||||
|
tests[instance.name] = obj
|
||||||
|
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return tests
|
||||||
|
|
||||||
|
|
||||||
|
def list_tests() -> None:
|
||||||
|
"""List all available DVT tests."""
|
||||||
|
tests = _discover_tests()
|
||||||
|
|
||||||
|
if not tests:
|
||||||
|
typer.echo("No tests found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
typer.echo("Available DVT tests:")
|
||||||
|
typer.echo("")
|
||||||
|
|
||||||
|
for test_name in sorted(tests.keys()):
|
||||||
|
test_class = tests[test_name]
|
||||||
|
instance = test_class()
|
||||||
|
typer.echo(f" {test_name:15s} {instance.description}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_test(
|
||||||
|
test_name: str,
|
||||||
|
config_file: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a specific DVT test.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_name: Name of the test to run.
|
||||||
|
config_file: Path to configuration YAML file.
|
||||||
|
operator: Operator identifier (e.g., email address).
|
||||||
|
description: Test run description.
|
||||||
|
"""
|
||||||
|
# Discover available tests
|
||||||
|
tests = _discover_tests()
|
||||||
|
|
||||||
|
if test_name not in tests:
|
||||||
|
typer.echo(f"Error: Test \'{test_name}\' not found.", err=True)
|
||||||
|
typer.echo("", err=True)
|
||||||
|
typer.echo("Available tests:", err=True)
|
||||||
|
for name in sorted(tests.keys()):
|
||||||
|
typer.echo(f" - {name}", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config_path = config_file or "config/default.yaml"
|
||||||
|
try:
|
||||||
|
config = load_config(config_path)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
typer.echo(f"Error: Configuration file not found: {config_path}", err=True)
|
||||||
|
typer.echo("Run with --config to specify a different config file.", err=True)
|
||||||
|
raise typer.Exit(code=1) from err
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error loading configuration: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create repository
|
||||||
|
try:
|
||||||
|
repository = SQLiteRepository(config.data.database_path)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error initialising repository: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create instruments
|
||||||
|
typer.echo(f"Connecting to instruments ({config.instruments.backend})...")
|
||||||
|
try:
|
||||||
|
# Convert AppConfig to InstrumentConfig
|
||||||
|
inst_config = InstrumentConfig(
|
||||||
|
backend=config.instruments.backend,
|
||||||
|
simulator_host=config.instruments.simulator.host,
|
||||||
|
chamber_port=config.instruments.simulator.thermal_chamber_port,
|
||||||
|
psu_port=config.instruments.simulator.power_supply_port,
|
||||||
|
dmm_port=config.instruments.simulator.multimeter_port,
|
||||||
|
chamber_visa=config.instruments.pyvisa.thermal_chamber,
|
||||||
|
psu_visa=config.instruments.pyvisa.power_supply,
|
||||||
|
dmm_visa=config.instruments.pyvisa.multimeter,
|
||||||
|
)
|
||||||
|
instruments = InstrumentFactory.create(inst_config)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error connecting to instruments: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create test instance
|
||||||
|
test_class = tests[test_name]
|
||||||
|
test = test_class()
|
||||||
|
|
||||||
|
# Run test
|
||||||
|
typer.echo(f"Running test: {test.name}")
|
||||||
|
typer.echo(f"Description: {test.description}")
|
||||||
|
typer.echo("")
|
||||||
|
|
||||||
|
try:
|
||||||
|
runner = TestRunner(repository)
|
||||||
|
run_id = runner.run_test(
|
||||||
|
test=test,
|
||||||
|
instruments=instruments,
|
||||||
|
operator=operator,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve final status
|
||||||
|
run = repository.get_run(run_id)
|
||||||
|
typer.echo("")
|
||||||
|
typer.echo(f"Test completed: {run.status.value}")
|
||||||
|
typer.echo(f"Run ID: {run_id}")
|
||||||
|
|
||||||
|
# Exit with appropriate code
|
||||||
|
if run.status.value == "PASSED":
|
||||||
|
raise typer.Exit(code=0)
|
||||||
|
else:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
typer.echo("")
|
||||||
|
typer.echo("Test interrupted by user.")
|
||||||
|
raise typer.Exit(code=130) from None
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error running test: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
@@ -70,6 +70,13 @@ class ITestRepository(ABC):
|
|||||||
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
|
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
|
||||||
"""Retrieve measurements as pandas DataFrame."""
|
"""Retrieve measurements as pandas DataFrame."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_all_runs(self) -> list[TestRun]:
|
||||||
|
"""Retrieve all test runs, ordered by started_at descending."""
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close repository and release resources. Optional to implement."""
|
||||||
|
|
||||||
|
|
||||||
class SQLiteRepository(ITestRepository):
|
class SQLiteRepository(ITestRepository):
|
||||||
"""SQLite-based repository for test data.
|
"""SQLite-based repository for test data.
|
||||||
@@ -357,3 +364,52 @@ class SQLiteRepository(ITestRepository):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return pd.read_parquet(parquet_path)
|
return pd.read_parquet(parquet_path)
|
||||||
|
|
||||||
|
def get_all_runs(self) -> list[TestRun]:
|
||||||
|
"""Retrieve all test runs, ordered by started_at descending.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all TestRun objects, newest first.
|
||||||
|
"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, test_name, started_at, status, config_json,
|
||||||
|
description, completed_at, operator, notes, created_at
|
||||||
|
FROM test_runs
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
TestRun(
|
||||||
|
id=row["id"],
|
||||||
|
test_name=row["test_name"],
|
||||||
|
started_at=datetime.fromisoformat(row["started_at"]),
|
||||||
|
status=TestStatus(row["status"]),
|
||||||
|
config_json=row["config_json"],
|
||||||
|
description=row["description"],
|
||||||
|
completed_at=(
|
||||||
|
datetime.fromisoformat(row["completed_at"])
|
||||||
|
if row["completed_at"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
operator=row["operator"],
|
||||||
|
notes=row["notes"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close repository and release resources.
|
||||||
|
|
||||||
|
SQLite connections are managed via context managers and auto-close.
|
||||||
|
This method performs explicit cleanup for Windows file handle issues.
|
||||||
|
"""
|
||||||
|
# Force garbage collection to release any lingering connections
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class InstrumentServer:
|
|||||||
handler,
|
handler,
|
||||||
self._host,
|
self._host,
|
||||||
port,
|
port,
|
||||||
|
reuse_address=True,
|
||||||
)
|
)
|
||||||
self._servers.append(server)
|
self._servers.append(server)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -216,7 +217,9 @@ class InstrumentServer:
|
|||||||
# Process command through instrument
|
# Process command through instrument
|
||||||
response = instrument.process(command)
|
response = instrument.process(command)
|
||||||
|
|
||||||
# Send response with newline terminator
|
# Send response with newline terminator (only if non-empty)
|
||||||
|
# Per SCPI protocol: commands that complete successfully without
|
||||||
|
# output do not send a response. Only queries and errors respond.
|
||||||
if response:
|
if response:
|
||||||
writer.write(f"{response}\n".encode())
|
writer.write(f"{response}\n".encode())
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|||||||
@@ -1,5 +1,58 @@
|
|||||||
"""Report generation.
|
"""Report generation module for py_dvt_ate.
|
||||||
|
|
||||||
Generates test reports from stored data in various formats
|
This module provides automated PDF report generation from test results.
|
||||||
including PDF and HTML.
|
Reports include test metadata, results tables, pass/fail status, and charts.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
>>> from uuid import UUID
|
||||||
|
>>> from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
>>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig
|
||||||
|
>>>
|
||||||
|
>>> # Create repository and generator
|
||||||
|
>>> repo = SQLiteRepository("./data/py_dvt_ate.db")
|
||||||
|
>>> config = ReportConfig(company_name="My Company", include_charts=True)
|
||||||
|
>>> generator = ReportGenerator(repo, config)
|
||||||
|
>>>
|
||||||
|
>>> # Generate PDF report
|
||||||
|
>>> run_id = UUID("12345678-1234-1234-1234-123456789abc")
|
||||||
|
>>> pdf_path = generator.generate(run_id)
|
||||||
|
>>> print(f"Report saved to: {pdf_path}")
|
||||||
|
>>>
|
||||||
|
>>> # Or get PDF as bytes (for streaming downloads)
|
||||||
|
>>> pdf_bytes = generator.generate_bytes(run_id)
|
||||||
|
|
||||||
|
Classes:
|
||||||
|
ReportGenerator: Main class for generating PDF reports.
|
||||||
|
ReportConfig: Configuration options for report generation.
|
||||||
|
ReportData: Data container for report content.
|
||||||
|
|
||||||
|
Exceptions:
|
||||||
|
ReportingError: Base exception for reporting errors.
|
||||||
|
ReportGenerationError: General report generation failures.
|
||||||
|
TemplateRenderError: HTML template rendering failures.
|
||||||
|
PDFConversionError: HTML to PDF conversion failures.
|
||||||
|
ChartGenerationError: Chart generation failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from py_dvt_ate.reporting.exceptions import (
|
||||||
|
ChartGenerationError,
|
||||||
|
PDFConversionError,
|
||||||
|
ReportGenerationError,
|
||||||
|
ReportingError,
|
||||||
|
TemplateRenderError,
|
||||||
|
)
|
||||||
|
from py_dvt_ate.reporting.generator import ReportGenerator
|
||||||
|
from py_dvt_ate.reporting.models import ReportConfig, ReportData
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Main classes
|
||||||
|
"ReportGenerator",
|
||||||
|
"ReportConfig",
|
||||||
|
"ReportData",
|
||||||
|
# Exceptions
|
||||||
|
"ReportingError",
|
||||||
|
"ReportGenerationError",
|
||||||
|
"TemplateRenderError",
|
||||||
|
"PDFConversionError",
|
||||||
|
"ChartGenerationError",
|
||||||
|
]
|
||||||
|
|||||||
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"]
|
||||||
233
src/py_dvt_ate/reporting/charts/matplotlib_charts.py
Normal file
233
src/py_dvt_ate/reporting/charts/matplotlib_charts.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""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
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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: tuple[Any, Any] | None = None
|
||||||
|
|
||||||
|
def _get_matplotlib(self) -> tuple[Any, Any]:
|
||||||
|
"""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
|
||||||
41
src/py_dvt_ate/reporting/exceptions.py
Normal file
41
src/py_dvt_ate/reporting/exceptions.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Exception classes for the reporting module.
|
||||||
|
|
||||||
|
This module defines a hierarchy of exceptions for report generation errors,
|
||||||
|
enabling specific error handling for different failure modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingError(Exception):
|
||||||
|
"""Base exception for all reporting-related errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class ReportGenerationError(ReportingError):
|
||||||
|
"""Raised when report generation fails.
|
||||||
|
|
||||||
|
This is the general error for failures during the report generation
|
||||||
|
process that don't fit into more specific categories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateRenderError(ReportingError):
|
||||||
|
"""Raised when HTML template rendering fails.
|
||||||
|
|
||||||
|
This typically indicates a problem with the Jinja2 template or
|
||||||
|
the data being passed to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PDFConversionError(ReportingError):
|
||||||
|
"""Raised when HTML to PDF conversion fails.
|
||||||
|
|
||||||
|
This typically indicates a problem with WeasyPrint or the generated
|
||||||
|
HTML/CSS being incompatible with PDF rendering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ChartGenerationError(ReportingError):
|
||||||
|
"""Raised when chart generation fails.
|
||||||
|
|
||||||
|
This typically indicates a problem with matplotlib or the measurement
|
||||||
|
data being charted.
|
||||||
|
"""
|
||||||
199
src/py_dvt_ate/reporting/generator.py
Normal file
199
src/py_dvt_ate/reporting/generator.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Report generator orchestrating the full report generation pipeline.
|
||||||
|
|
||||||
|
This module provides the main ReportGenerator class that coordinates
|
||||||
|
data gathering, chart generation, HTML rendering, and PDF conversion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from py_dvt_ate.data.repository import ITestRepository
|
||||||
|
from py_dvt_ate.reporting.charts import ChartGenerator
|
||||||
|
from py_dvt_ate.reporting.exceptions import ReportGenerationError
|
||||||
|
from py_dvt_ate.reporting.models import ReportConfig, ReportData
|
||||||
|
from py_dvt_ate.reporting.renderers import HTMLRenderer, PDFRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class IReportGenerator(Protocol):
|
||||||
|
"""Protocol for report generators."""
|
||||||
|
|
||||||
|
def generate(self, run_id: UUID, output_path: Path | None = None) -> Path:
|
||||||
|
"""Generate a PDF report for a test run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: UUID of the test run.
|
||||||
|
output_path: Optional output path. If None, uses default location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the generated PDF file.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def generate_bytes(self, run_id: UUID) -> bytes:
|
||||||
|
"""Generate a PDF report and return as bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: UUID of the test run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF document as bytes.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ReportGenerator:
|
||||||
|
"""Generates PDF reports from test run data.
|
||||||
|
|
||||||
|
This class orchestrates the full report generation pipeline:
|
||||||
|
1. Fetch test run data from repository
|
||||||
|
2. Generate charts from measurements
|
||||||
|
3. Render HTML from templates
|
||||||
|
4. Convert HTML to PDF
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
>>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig
|
||||||
|
>>>
|
||||||
|
>>> repo = SQLiteRepository("./data/py_dvt_ate.db")
|
||||||
|
>>> config = ReportConfig(company_name="My Company")
|
||||||
|
>>> generator = ReportGenerator(repo, config)
|
||||||
|
>>> pdf_path = generator.generate(run_id)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: ITestRepository,
|
||||||
|
config: ReportConfig | None = None,
|
||||||
|
reports_dir: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialise the report generator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repository: Test data repository for fetching run data.
|
||||||
|
config: Report configuration. Uses defaults if not provided.
|
||||||
|
reports_dir: Directory for generated reports. Defaults to ./data/reports.
|
||||||
|
"""
|
||||||
|
self.repository = repository
|
||||||
|
self.config = config or ReportConfig()
|
||||||
|
self.reports_dir = reports_dir or Path("./data/reports")
|
||||||
|
|
||||||
|
self._html_renderer = HTMLRenderer()
|
||||||
|
self._pdf_renderer = PDFRenderer()
|
||||||
|
self._chart_generator = ChartGenerator(dpi=self.config.chart_dpi)
|
||||||
|
|
||||||
|
def _gather_data(self, run_id: UUID) -> ReportData:
|
||||||
|
"""Gather all data needed for the report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: UUID of the test run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReportData containing run, results, measurements, and charts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReportGenerationError: If data gathering fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
run = self.repository.get_run(run_id)
|
||||||
|
results = self.repository.get_results(run_id)
|
||||||
|
measurements = self.repository.get_measurements_dataframe(run_id)
|
||||||
|
|
||||||
|
# Generate charts if enabled
|
||||||
|
charts: dict[str, str] = {}
|
||||||
|
if self.config.include_charts:
|
||||||
|
charts = self._chart_generator.generate_all(run, results, measurements)
|
||||||
|
|
||||||
|
return ReportData(
|
||||||
|
run=run,
|
||||||
|
results=results,
|
||||||
|
measurements=measurements,
|
||||||
|
charts=charts,
|
||||||
|
config=self.config,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
msg = f"Failed to gather data for run {run_id}: {e}"
|
||||||
|
raise ReportGenerationError(msg) from e
|
||||||
|
|
||||||
|
def _generate_filename(self, run_id: UUID, test_name: str) -> str:
|
||||||
|
"""Generate a filename for the report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: UUID of the test run.
|
||||||
|
test_name: Name of the test.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filename string with timestamp.
|
||||||
|
"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
safe_name = test_name.replace(" ", "_").replace("/", "_")
|
||||||
|
return f"{safe_name}_{str(run_id)[:8]}_{timestamp}.pdf"
|
||||||
|
|
||||||
|
def generate(self, run_id: UUID, output_path: Path | None = None) -> Path:
|
||||||
|
"""Generate a PDF report for a test run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: UUID of the test run.
|
||||||
|
output_path: Optional output path. If None, uses default location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the generated PDF file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReportGenerationError: If report generation fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Gather data
|
||||||
|
data = self._gather_data(run_id)
|
||||||
|
|
||||||
|
# Determine output path
|
||||||
|
if output_path is None:
|
||||||
|
self.reports_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = self._generate_filename(run_id, data.run.test_name)
|
||||||
|
output_path = self.reports_dir / filename
|
||||||
|
|
||||||
|
# Render HTML
|
||||||
|
html = self._html_renderer.render(data)
|
||||||
|
|
||||||
|
# Convert to PDF
|
||||||
|
self._pdf_renderer.render_to_file(html, output_path)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except ReportGenerationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Failed to generate report for run {run_id}: {e}"
|
||||||
|
raise ReportGenerationError(msg) from e
|
||||||
|
|
||||||
|
def generate_bytes(self, run_id: UUID) -> bytes:
|
||||||
|
"""Generate a PDF report and return as bytes.
|
||||||
|
|
||||||
|
Useful for streaming downloads without writing to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_id: UUID of the test run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF document as bytes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ReportGenerationError: If report generation fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Gather data
|
||||||
|
data = self._gather_data(run_id)
|
||||||
|
|
||||||
|
# Render HTML
|
||||||
|
html = self._html_renderer.render(data)
|
||||||
|
|
||||||
|
# Convert to PDF bytes
|
||||||
|
return self._pdf_renderer.render_to_bytes(html)
|
||||||
|
|
||||||
|
except ReportGenerationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Failed to generate report bytes for run {run_id}: {e}"
|
||||||
|
raise ReportGenerationError(msg) from e
|
||||||
70
src/py_dvt_ate/reporting/models.py
Normal file
70
src/py_dvt_ate/reporting/models.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Data models for report generation.
|
||||||
|
|
||||||
|
This module defines dataclasses for report configuration and data structures
|
||||||
|
used throughout the reporting pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import TestResult, TestRun
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReportConfig:
|
||||||
|
"""Configuration for report generation.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
company_name: Company name to display in report header.
|
||||||
|
logo_path: Path to company logo image file (optional).
|
||||||
|
include_charts: Whether to include charts in the report.
|
||||||
|
chart_dpi: DPI for chart images (higher = better quality but larger file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
company_name: str = "py_dvt_ate"
|
||||||
|
logo_path: Path | None = None
|
||||||
|
include_charts: bool = True
|
||||||
|
chart_dpi: int = 150
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReportData:
|
||||||
|
"""Data container for report generation.
|
||||||
|
|
||||||
|
Contains all data needed to generate a test report including
|
||||||
|
test run metadata, results, measurements, and generated charts.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
run: Test run metadata.
|
||||||
|
results: List of test results with pass/fail status.
|
||||||
|
measurements: DataFrame of time-series measurements (optional).
|
||||||
|
charts: Dictionary mapping chart names to base64-encoded PNG images.
|
||||||
|
config: Report configuration settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
run: TestRun
|
||||||
|
results: list[TestResult]
|
||||||
|
measurements: pd.DataFrame | None = None
|
||||||
|
charts: dict[str, str] = field(default_factory=dict)
|
||||||
|
config: ReportConfig = field(default_factory=ReportConfig)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed_count(self) -> int:
|
||||||
|
"""Count of results that passed."""
|
||||||
|
return sum(1 for r in self.results if r.passed is True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed_count(self) -> int:
|
||||||
|
"""Count of results that failed."""
|
||||||
|
return sum(1 for r in self.results if r.passed is False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overall_status(self) -> str:
|
||||||
|
"""Overall test status: PASS, FAIL, or ERROR."""
|
||||||
|
if self.run.status.value == "error":
|
||||||
|
return "ERROR"
|
||||||
|
if self.failed_count > 0:
|
||||||
|
return "FAIL"
|
||||||
|
return "PASS"
|
||||||
6
src/py_dvt_ate/reporting/renderers/__init__.py
Normal file
6
src/py_dvt_ate/reporting/renderers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Report renderers for HTML and PDF output."""
|
||||||
|
|
||||||
|
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
|
||||||
|
from py_dvt_ate.reporting.renderers.pdf import PDFRenderer
|
||||||
|
|
||||||
|
__all__ = ["HTMLRenderer", "PDFRenderer"]
|
||||||
100
src/py_dvt_ate/reporting/renderers/html.py
Normal file
100
src/py_dvt_ate/reporting/renderers/html.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""HTML renderer using Jinja2 templates.
|
||||||
|
|
||||||
|
This module provides HTML rendering for test reports using Jinja2 templating.
|
||||||
|
Templates are loaded from the package's templates directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||||
|
|
||||||
|
from py_dvt_ate import __version__
|
||||||
|
from py_dvt_ate.reporting.exceptions import TemplateRenderError
|
||||||
|
from py_dvt_ate.reporting.models import ReportData
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLRenderer:
|
||||||
|
"""Renders HTML reports from ReportData using Jinja2 templates.
|
||||||
|
|
||||||
|
The renderer loads templates from the py_dvt_ate.reporting.templates package
|
||||||
|
and provides methods for rendering complete HTML reports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialise the HTML renderer with Jinja2 environment."""
|
||||||
|
self._env = Environment(
|
||||||
|
loader=PackageLoader("py_dvt_ate.reporting", "templates"),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
)
|
||||||
|
self._css_content: str | None = None
|
||||||
|
|
||||||
|
def _load_css(self) -> str:
|
||||||
|
"""Load CSS content from the templates directory."""
|
||||||
|
if self._css_content is None:
|
||||||
|
templates_pkg = resources.files("py_dvt_ate.reporting.templates")
|
||||||
|
css_file = templates_pkg.joinpath("styles.css")
|
||||||
|
self._css_content = css_file.read_text()
|
||||||
|
return self._css_content
|
||||||
|
|
||||||
|
def _load_logo(self, logo_path: Path | None) -> str | None:
|
||||||
|
"""Load and encode logo image as base64.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logo_path: Path to logo image file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded image data, or None if no logo or file not found.
|
||||||
|
"""
|
||||||
|
if logo_path is None or not logo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with logo_path.open("rb") as f:
|
||||||
|
return base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def render(self, data: ReportData) -> str:
|
||||||
|
"""Render a test report to HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Report data containing test run, results, and charts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete HTML document as a string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TemplateRenderError: If template rendering fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
template = self._env.get_template("test_report.html")
|
||||||
|
|
||||||
|
# Format config JSON for display
|
||||||
|
config_formatted = ""
|
||||||
|
if data.run.config_json:
|
||||||
|
try:
|
||||||
|
config_dict = json.loads(data.run.config_json)
|
||||||
|
config_formatted = json.dumps(config_dict, indent=2)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
config_formatted = data.run.config_json
|
||||||
|
|
||||||
|
# Prepare template context
|
||||||
|
context = {
|
||||||
|
"data": data,
|
||||||
|
"css_content": self._load_css(),
|
||||||
|
"logo_base64": self._load_logo(data.config.logo_path),
|
||||||
|
"company_name": data.config.company_name,
|
||||||
|
"version": __version__,
|
||||||
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"config_formatted": config_formatted,
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.render(**context)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Failed to render HTML template: {e}"
|
||||||
|
raise TemplateRenderError(msg) from e
|
||||||
84
src/py_dvt_ate/reporting/renderers/pdf.py
Normal file
84
src/py_dvt_ate/reporting/renderers/pdf.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""PDF renderer using WeasyPrint.
|
||||||
|
|
||||||
|
This module provides PDF rendering from HTML content using WeasyPrint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from py_dvt_ate.reporting.exceptions import PDFConversionError
|
||||||
|
|
||||||
|
|
||||||
|
class PDFRenderer:
|
||||||
|
"""Renders PDF documents from HTML content using WeasyPrint.
|
||||||
|
|
||||||
|
WeasyPrint converts HTML/CSS to PDF with support for page layout,
|
||||||
|
headers/footers, and professional typography.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialise the PDF renderer."""
|
||||||
|
self._weasyprint: type | None = None
|
||||||
|
|
||||||
|
def _get_weasyprint(self) -> type:
|
||||||
|
"""Lazy-load WeasyPrint to avoid import errors when not installed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The WeasyPrint HTML class.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PDFConversionError: If WeasyPrint is not installed.
|
||||||
|
"""
|
||||||
|
if self._weasyprint is None:
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
self._weasyprint = HTML
|
||||||
|
except ImportError as e:
|
||||||
|
msg = (
|
||||||
|
"WeasyPrint is required for PDF generation. "
|
||||||
|
"Install it with: pip install py_dvt_ate[reports]"
|
||||||
|
)
|
||||||
|
raise PDFConversionError(msg) from e
|
||||||
|
return self._weasyprint
|
||||||
|
|
||||||
|
def render_to_file(self, html: str, path: Path) -> None:
|
||||||
|
"""Render HTML content to a PDF file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML content to convert.
|
||||||
|
path: Output path for the PDF file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PDFConversionError: If PDF conversion fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
HTML = self._get_weasyprint()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
HTML(string=html).write_pdf(path)
|
||||||
|
except PDFConversionError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Failed to convert HTML to PDF: {e}"
|
||||||
|
raise PDFConversionError(msg) from e
|
||||||
|
|
||||||
|
def render_to_bytes(self, html: str) -> bytes:
|
||||||
|
"""Render HTML content to PDF bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML content to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF document as bytes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PDFConversionError: If PDF conversion fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
HTML = self._get_weasyprint()
|
||||||
|
result: bytes = HTML(string=html).write_pdf()
|
||||||
|
return result
|
||||||
|
except PDFConversionError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Failed to convert HTML to PDF: {e}"
|
||||||
|
raise PDFConversionError(msg) from e
|
||||||
33
src/py_dvt_ate/reporting/templates/base.html
Normal file
33
src/py_dvt_ate/reporting/templates/base.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Test Report{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
{{ css_content }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="report-header">
|
||||||
|
<div class="logo-block">
|
||||||
|
{% if logo_base64 %}
|
||||||
|
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Company Logo" class="logo">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="title-block">
|
||||||
|
<h1>{% block header_title %}Test Report{% endblock %}</h1>
|
||||||
|
<div class="subtitle">{{ company_name }}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="report-footer">
|
||||||
|
<p>Generated by py_dvt_ate v{{ version }} on {{ generated_at }}</p>
|
||||||
|
<p>{{ company_name }}</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
src/py_dvt_ate/reporting/templates/styles.css
Normal file
319
src/py_dvt_ate/reporting/templates/styles.css
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
/* Professional report stylesheet for py_dvt_ate
|
||||||
|
* Optimised for A4 PDF output via WeasyPrint
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Page setup for A4 */
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 20mm 15mm 25mm 15mm;
|
||||||
|
|
||||||
|
@bottom-center {
|
||||||
|
content: "Page " counter(page) " of " counter(pages);
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bottom-right {
|
||||||
|
content: "py_dvt_ate Report";
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers */
|
||||||
|
h1 {
|
||||||
|
font-size: 20pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 10mm 0;
|
||||||
|
padding-bottom: 3mm;
|
||||||
|
border-bottom: 2px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 14pt;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 8mm 0 4mm 0;
|
||||||
|
padding-bottom: 2mm;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 12pt;
|
||||||
|
color: #374151;
|
||||||
|
margin: 6mm 0 3mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Report header section */
|
||||||
|
.report-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8mm;
|
||||||
|
padding-bottom: 5mm;
|
||||||
|
border-bottom: 3px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header .logo {
|
||||||
|
max-height: 20mm;
|
||||||
|
max-width: 50mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header .title-block {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header .title-block h1 {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header .title-block .subtitle {
|
||||||
|
font-size: 11pt;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metadata section */
|
||||||
|
.metadata {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 3mm;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
padding: 4mm;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 35mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-item .value {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary cards */
|
||||||
|
.summary-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 4mm;
|
||||||
|
margin: 6mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5mm;
|
||||||
|
border-radius: 2mm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.pass {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.fail {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.info {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .count {
|
||||||
|
font-size: 24pt;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.pass .count {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.fail .count {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card.info .count {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .label {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1mm 3mm;
|
||||||
|
border-radius: 1mm;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pass {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.fail {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 4mm 0;
|
||||||
|
font-size: 9pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 2.5mm 3mm;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
border-bottom: 2px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Numeric columns right-aligned */
|
||||||
|
td.numeric {
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results table specific */
|
||||||
|
.results-table .status-cell {
|
||||||
|
text-align: center;
|
||||||
|
width: 15mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .value-cell {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .limit-cell {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
text-align: right;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts section */
|
||||||
|
.chart-container {
|
||||||
|
margin: 6mm 0;
|
||||||
|
text-align: center;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container .caption {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 2mm;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Configuration section */
|
||||||
|
.config-section {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 4mm;
|
||||||
|
border-radius: 2mm;
|
||||||
|
margin: 4mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section pre {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 8pt;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page break utilities */
|
||||||
|
.page-break-before {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-break-after {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avoid-break {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.report-footer {
|
||||||
|
margin-top: 10mm;
|
||||||
|
padding-top: 4mm;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print optimisations */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/py_dvt_ate/reporting/templates/test_report.html
Normal file
125
src/py_dvt_ate/reporting/templates/test_report.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ data.run.test_name }} - Test Report{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}{{ data.run.test_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="test-overview">
|
||||||
|
<h2>Test Overview</h2>
|
||||||
|
<div class="metadata">
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="label">Run ID:</span>
|
||||||
|
<span class="value">{{ data.run.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="label">Test Name:</span>
|
||||||
|
<span class="value">{{ data.run.test_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="label">Started:</span>
|
||||||
|
<span class="value">{{ data.run.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="label">Completed:</span>
|
||||||
|
<span class="value">{% if data.run.completed_at %}{{ data.run.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}N/A{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="label">Operator:</span>
|
||||||
|
<span class="value">{{ data.run.operator or 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metadata-item">
|
||||||
|
<span class="label">Status:</span>
|
||||||
|
<span class="value">
|
||||||
|
<span class="status-badge {{ data.overall_status|lower }}">{{ data.overall_status }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if data.run.description %}
|
||||||
|
<div class="metadata-item" style="grid-column: span 2;">
|
||||||
|
<span class="label">Description:</span>
|
||||||
|
<span class="value">{{ data.run.description }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results-summary">
|
||||||
|
<h2>Results Summary</h2>
|
||||||
|
<div class="summary-cards">
|
||||||
|
<div class="summary-card pass">
|
||||||
|
<div class="count">{{ data.passed_count }}</div>
|
||||||
|
<div class="label">Passed</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card fail">
|
||||||
|
<div class="count">{{ data.failed_count }}</div>
|
||||||
|
<div class="label">Failed</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card info">
|
||||||
|
<div class="count">{{ data.results|length }}</div>
|
||||||
|
<div class="label">Total Results</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results-table-section avoid-break">
|
||||||
|
<h2>Test Results</h2>
|
||||||
|
{% if data.results %}
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Parameter</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Unit</th>
|
||||||
|
<th>Lower Limit</th>
|
||||||
|
<th>Upper Limit</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for result in data.results %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ result.parameter }}</td>
|
||||||
|
<td class="value-cell">{{ "%.6f"|format(result.value) }}</td>
|
||||||
|
<td>{{ result.unit }}</td>
|
||||||
|
<td class="limit-cell">{% if result.lower_limit is not none %}{{ "%.6f"|format(result.lower_limit) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="limit-cell">{% if result.upper_limit is not none %}{{ "%.6f"|format(result.upper_limit) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="status-cell">
|
||||||
|
{% if result.passed is true %}
|
||||||
|
<span class="status-badge pass">PASS</span>
|
||||||
|
{% elif result.passed is false %}
|
||||||
|
<span class="status-badge fail">FAIL</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge pending">N/A</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No results recorded for this test run.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if data.charts %}
|
||||||
|
<section class="charts-section page-break-before">
|
||||||
|
<h2>Charts</h2>
|
||||||
|
{% for chart_name, chart_base64 in data.charts.items() %}
|
||||||
|
<div class="chart-container avoid-break">
|
||||||
|
<h3>{{ chart_name }}</h3>
|
||||||
|
<img src="data:image/png;base64,{{ chart_base64 }}" alt="{{ chart_name }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.run.config_json %}
|
||||||
|
<section class="configuration-section avoid-break">
|
||||||
|
<h2>Test Configuration</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<pre>{{ config_formatted }}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -57,17 +57,39 @@ class SimulationServer:
|
|||||||
self._instrument_server: InstrumentServer | None = None
|
self._instrument_server: InstrumentServer | None = None
|
||||||
self._physics_task: asyncio.Task[None] | None = None
|
self._physics_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._paused = False # Pause physics simulation
|
||||||
|
self._time_scale = 1.0 # Simulation time multiplier
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if server is currently running."""
|
"""Check if server is currently running."""
|
||||||
return self._running
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self) -> bool:
|
||||||
|
"""Check if physics simulation is paused."""
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
@paused.setter
|
||||||
|
def paused(self, value: bool) -> None:
|
||||||
|
"""Pause or resume the physics simulation."""
|
||||||
|
self._paused = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def physics_engine(self) -> PhysicsEngine | None:
|
def physics_engine(self) -> PhysicsEngine | None:
|
||||||
"""Get the physics engine instance."""
|
"""Get the physics engine instance."""
|
||||||
return self._physics_engine
|
return self._physics_engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_scale(self) -> float:
|
||||||
|
"""Get the current time scale multiplier."""
|
||||||
|
return self._time_scale
|
||||||
|
|
||||||
|
@time_scale.setter
|
||||||
|
def time_scale(self, value: float) -> None:
|
||||||
|
"""Set the time scale multiplier (e.g., 10.0 = 10x faster)."""
|
||||||
|
self._time_scale = max(0.1, min(value, 1000.0))
|
||||||
|
|
||||||
def _setup(self) -> None:
|
def _setup(self) -> None:
|
||||||
"""Create and wire up all components."""
|
"""Create and wire up all components."""
|
||||||
# Create physics engine
|
# Create physics engine
|
||||||
@@ -101,8 +123,12 @@ class SimulationServer:
|
|||||||
dt = self._physics_engine.dt
|
dt = self._physics_engine.dt
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
|
if not self._paused:
|
||||||
|
# Step physics multiple times based on time scale
|
||||||
|
steps_per_tick = max(1, int(self._time_scale))
|
||||||
|
for _ in range(steps_per_tick):
|
||||||
self._physics_engine.step()
|
self._physics_engine.step()
|
||||||
# Sleep for the physics timestep
|
# Sleep for the physics timestep (wall clock time)
|
||||||
await asyncio.sleep(dt)
|
await asyncio.sleep(dt)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
TEMP:SETPOINT? - Query current setpoint
|
TEMP:SETPOINT? - Query current setpoint
|
||||||
TEMP:ACTUAL? - Query actual chamber temperature
|
TEMP:ACTUAL? - Query actual chamber temperature
|
||||||
TEMP:STAB? - Query temperature stability (1=stable, 0=settling)
|
TEMP:STAB? - Query temperature stability (1=stable, 0=settling)
|
||||||
|
TEMP:RAMP <value> - Set temperature ramp rate in degrees C/min
|
||||||
|
TEMP:RAMP? - Query current ramp rate
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
manufacturer: "PyDVTATE"
|
manufacturer: "PyDVTATE"
|
||||||
@@ -47,6 +49,7 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
physics_engine: Reference to physics engine for temperature state.
|
physics_engine: Reference to physics engine for temperature state.
|
||||||
"""
|
"""
|
||||||
self._setpoint = 25.0 # Default setpoint
|
self._setpoint = 25.0 # Default setpoint
|
||||||
|
self._ramp_rate = 10.0 # Default ramp rate in degrees C/min
|
||||||
super().__init__(physics_engine)
|
super().__init__(physics_engine)
|
||||||
|
|
||||||
def _setup_commands(self) -> None:
|
def _setup_commands(self) -> None:
|
||||||
@@ -54,10 +57,12 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
self.register_command("TEMP:SETPOINT", self._handle_temp_setpoint)
|
self.register_command("TEMP:SETPOINT", self._handle_temp_setpoint)
|
||||||
self.register_command("TEMP:ACTUAL", self._handle_temp_actual)
|
self.register_command("TEMP:ACTUAL", self._handle_temp_actual)
|
||||||
self.register_command("TEMP:STAB", self._handle_temp_stab)
|
self.register_command("TEMP:STAB", self._handle_temp_stab)
|
||||||
|
self.register_command("TEMP:RAMP", self._handle_temp_ramp)
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""Reset chamber to default state."""
|
"""Reset chamber to default state."""
|
||||||
self._setpoint = 25.0
|
self._setpoint = 25.0
|
||||||
|
self._ramp_rate = 10.0
|
||||||
if self._physics_engine is not None:
|
if self._physics_engine is not None:
|
||||||
self._physics_engine.set_chamber_setpoint(self._setpoint)
|
self._physics_engine.set_chamber_setpoint(self._setpoint)
|
||||||
|
|
||||||
@@ -141,3 +146,36 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
if error <= self.STABILITY_THRESHOLD:
|
if error <= self.STABILITY_THRESHOLD:
|
||||||
return "1"
|
return "1"
|
||||||
return "0"
|
return "0"
|
||||||
|
|
||||||
|
def _handle_temp_ramp(self, command: SCPICommand) -> str:
|
||||||
|
"""Handle TEMP:RAMP command/query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Parsed SCPI command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ramp rate value for query, empty string for set command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ramp rate argument is invalid.
|
||||||
|
"""
|
||||||
|
if command.is_query:
|
||||||
|
return f"{self._ramp_rate:.2f}"
|
||||||
|
|
||||||
|
# Set command requires one argument
|
||||||
|
if not command.arguments:
|
||||||
|
raise ValueError("TEMP:RAMP requires a value")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ramp_rate = float(command.arguments[0])
|
||||||
|
except ValueError as err:
|
||||||
|
raise ValueError(f"Invalid ramp rate value: {command.arguments[0]}") from err
|
||||||
|
|
||||||
|
if ramp_rate <= 0:
|
||||||
|
raise ValueError("Ramp rate must be positive")
|
||||||
|
|
||||||
|
self._ramp_rate = ramp_rate
|
||||||
|
# Note: Simulator doesn't currently model ramp rate dynamics
|
||||||
|
# The value is stored but not used in physics calculations
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This test characterises the output voltage temperature coefficient by
|
This test characterises the output voltage temperature coefficient by
|
||||||
sweeping the chamber temperature and measuring output voltage at each point.
|
sweeping the chamber temperature and measuring output voltage at each point.
|
||||||
The TempCo is calculated from the linear regression slope and expressed
|
The TempCo is calculated from the linear regression slope and expressed
|
||||||
in parts per million per degree Celsius (ppm/°C).
|
in parts per million per degree Celsius (ppm/C).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from py_dvt_ate.data.models import TestStatus
|
from py_dvt_ate.data.models import TestStatus
|
||||||
@@ -29,12 +29,12 @@ class TempCoTest(BaseDVTTest):
|
|||||||
5. Evaluate against specification limits
|
5. Evaluate against specification limits
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
temperatures: List of temperature points (°C). Default: [-40, -20, 0, 25, 50, 85]
|
temperatures: List of temperature points (C). Default: [-40, -20, 0, 25, 50, 85]
|
||||||
input_voltage: DUT input voltage (V). Default: 5.0
|
input_voltage: DUT input voltage (V). Default: 5.0
|
||||||
load_current: DUT load current (A). Default: 0.1
|
load_current: DUT load current (A). Default: 0.1
|
||||||
settle_time: Additional settling time at each temp (s). Default: 5.0
|
settle_time: Additional settling time at each temp (s). Default: 5.0
|
||||||
num_samples: Number of measurements to average per point. Default: 5
|
num_samples: Number of measurements to average per point. Default: 5
|
||||||
tempco_limit: Maximum allowed TempCo magnitude (ppm/°C). Default: ±50.0
|
tempco_limit: Maximum allowed TempCo magnitude (ppm/C). Default: +/-50.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -90,7 +90,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
# Temperature sweep
|
# Temperature sweep
|
||||||
for temp_setpoint in temperatures:
|
for temp_setpoint in temperatures:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Temperature point: {temp_setpoint}°C",
|
f"Temperature point: {temp_setpoint}C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
)
|
)
|
||||||
if not stable:
|
if not stable:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Warning: Temperature did not stabilise at {temp_setpoint}°C",
|
f"Warning: Temperature did not stabilise at {temp_setpoint}C",
|
||||||
level="WARNING",
|
level="WARNING",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,8 +133,8 @@ class TempCoTest(BaseDVTTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Measured Vout = {vout_mean:.6f}V ± {vout_std * 1e6:.1f}μV "
|
f"Measured Vout = {vout_mean:.6f}V +/- {vout_std * 1e6:.1f}uV "
|
||||||
f"at T={actual_temp:.2f}°C",
|
f"at T={actual_temp:.2f}C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
tempco_ppm = self._calculate_tempco(temp_points, vout_points)
|
tempco_ppm = self._calculate_tempco(temp_points, vout_points)
|
||||||
|
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Calculated TempCo = {tempco_ppm:.2f} ppm/°C",
|
f"Calculated TempCo = {tempco_ppm:.2f} ppm/C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
context.logger.log_result(
|
context.logger.log_result(
|
||||||
parameter="temp_co",
|
parameter="temp_co",
|
||||||
value=tempco_ppm,
|
value=tempco_ppm,
|
||||||
unit="ppm/°C",
|
unit="ppm/C",
|
||||||
lower_limit=-abs(tempco_limit),
|
lower_limit=-abs(tempco_limit),
|
||||||
upper_limit=abs(tempco_limit),
|
upper_limit=abs(tempco_limit),
|
||||||
)
|
)
|
||||||
@@ -164,13 +164,13 @@ class TempCoTest(BaseDVTTest):
|
|||||||
|
|
||||||
if passed:
|
if passed:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"TempCo test PASSED: {tempco_ppm:.2f} ppm/°C within ±{tempco_limit} ppm/°C",
|
f"TempCo test PASSED: {tempco_ppm:.2f} ppm/C within +/-{tempco_limit} ppm/C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
return TestStatus.PASSED
|
return TestStatus.PASSED
|
||||||
else:
|
else:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"TempCo test FAILED: {tempco_ppm:.2f} ppm/°C exceeds ±{tempco_limit} ppm/°C",
|
f"TempCo test FAILED: {tempco_ppm:.2f} ppm/C exceeds +/-{tempco_limit} ppm/C",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
)
|
)
|
||||||
return TestStatus.FAILED
|
return TestStatus.FAILED
|
||||||
@@ -198,14 +198,14 @@ class TempCoTest(BaseDVTTest):
|
|||||||
"""Calculate temperature coefficient from measurements.
|
"""Calculate temperature coefficient from measurements.
|
||||||
|
|
||||||
Uses linear regression to find the slope (dV/dT), then converts
|
Uses linear regression to find the slope (dV/dT), then converts
|
||||||
to ppm/°C relative to the nominal voltage (voltage at median temperature).
|
to ppm/C relative to the nominal voltage (voltage at median temperature).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
temperatures: Temperature measurements in °C.
|
temperatures: Temperature measurements in C.
|
||||||
voltages: Output voltage measurements in V.
|
voltages: Output voltage measurements in V.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Temperature coefficient in ppm/°C.
|
Temperature coefficient in ppm/C.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If insufficient data points.
|
ValueError: If insufficient data points.
|
||||||
@@ -230,14 +230,14 @@ class TempCoTest(BaseDVTTest):
|
|||||||
if var_t == 0:
|
if var_t == 0:
|
||||||
raise ValueError("Temperature variance is zero (all temps identical)")
|
raise ValueError("Temperature variance is zero (all temps identical)")
|
||||||
|
|
||||||
slope = cov / var_t # dV/dT in V/°C
|
slope = cov / var_t # dV/dT in V/C
|
||||||
|
|
||||||
# Find nominal voltage (voltage at median temperature)
|
# Find nominal voltage (voltage at median temperature)
|
||||||
sorted_pairs = sorted(zip(temperatures, voltages, strict=True))
|
sorted_pairs = sorted(zip(temperatures, voltages, strict=True))
|
||||||
mid_idx = len(sorted_pairs) // 2
|
mid_idx = len(sorted_pairs) // 2
|
||||||
v_nominal = sorted_pairs[mid_idx][1]
|
v_nominal = sorted_pairs[mid_idx][1]
|
||||||
|
|
||||||
# Convert to ppm/°C: (dV/dT) / V_nom * 10^6
|
# Convert to ppm/C: (dV/dT) / V_nom * 10^6
|
||||||
tempco_ppm = (slope / v_nominal) * 1e6
|
tempco_ppm = (slope / v_nominal) * 1e6
|
||||||
|
|
||||||
return tempco_ppm
|
return tempco_ppm
|
||||||
|
|||||||
@@ -1 +1,135 @@
|
|||||||
"""Configuration for integration tests."""
|
"""Configuration for integration tests."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
||||||
|
|
||||||
|
|
||||||
|
class ServerThread:
|
||||||
|
"""Helper class to run SimulationServer in a background thread.
|
||||||
|
|
||||||
|
This allows synchronous test code to run in the main thread while
|
||||||
|
the async server runs in its own thread with its own event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ServerConfig):
|
||||||
|
"""Initialise the server thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Server configuration.
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.server: SimulationServer | None = None
|
||||||
|
self.thread: threading.Thread | None = None
|
||||||
|
self.loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._started = threading.Event()
|
||||||
|
self._error: Exception | None = None
|
||||||
|
|
||||||
|
def _run_server(self) -> None:
|
||||||
|
"""Run the server in the background thread."""
|
||||||
|
try:
|
||||||
|
# Create new event loop for this thread
|
||||||
|
self.loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self.loop)
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
self.server = SimulationServer(self.config)
|
||||||
|
self.loop.run_until_complete(self.server.start())
|
||||||
|
|
||||||
|
# Signal that server is started
|
||||||
|
self._started.set()
|
||||||
|
|
||||||
|
# Run event loop until stopped
|
||||||
|
self.loop.run_forever()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._error = e
|
||||||
|
self._started.set() # Unblock waiting thread even on error
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
if self.server is not None and self.loop is not None:
|
||||||
|
try:
|
||||||
|
self.loop.run_until_complete(self.server.stop())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.loop is not None:
|
||||||
|
self.loop.close()
|
||||||
|
|
||||||
|
def start(self, timeout: float = 5.0) -> None:
|
||||||
|
"""Start the server thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for server to start (seconds).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If server fails to start within timeout.
|
||||||
|
Exception: If server raises an exception during startup.
|
||||||
|
"""
|
||||||
|
self.thread = threading.Thread(target=self._run_server, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
# Wait for server to start
|
||||||
|
if not self._started.wait(timeout=timeout):
|
||||||
|
raise RuntimeError("Server failed to start within timeout")
|
||||||
|
|
||||||
|
# Check if there was an error during startup
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
# Give server a bit more time to fully initialize
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0) -> None:
|
||||||
|
"""Stop the server thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for server to stop (seconds).
|
||||||
|
"""
|
||||||
|
if self.loop is not None and self.loop.is_running():
|
||||||
|
# Schedule stop in the server's event loop
|
||||||
|
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||||
|
|
||||||
|
if self.thread is not None:
|
||||||
|
self.thread.join(timeout=timeout)
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def simulation_server() -> Generator[ServerConfig, None, None]:
|
||||||
|
"""Provide a running simulation server for integration tests.
|
||||||
|
|
||||||
|
The server runs in a background thread with its own event loop,
|
||||||
|
allowing synchronous test code to run in the main thread.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
ServerConfig with connection details for the running server.
|
||||||
|
"""
|
||||||
|
# Use unique ports for each test to avoid conflicts
|
||||||
|
import random
|
||||||
|
|
||||||
|
base_port = random.randint(20000, 30000)
|
||||||
|
|
||||||
|
config = ServerConfig(
|
||||||
|
host="127.0.0.1",
|
||||||
|
chamber_port=base_port,
|
||||||
|
psu_port=base_port + 1,
|
||||||
|
dmm_port=base_port + 2,
|
||||||
|
physics_rate_hz=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_thread = ServerThread(config)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# Speed up simulation for tests (100x faster)
|
||||||
|
if server_thread.server is not None:
|
||||||
|
server_thread.server.time_scale = 100.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield config
|
||||||
|
finally:
|
||||||
|
server_thread.stop()
|
||||||
|
|||||||
289
tests/integration/test_e2e.py
Normal file
289
tests/integration/test_e2e.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""End-to-end integration tests for py_dvt_ate.
|
||||||
|
|
||||||
|
This module contains comprehensive tests that exercise the entire system:
|
||||||
|
- Simulation server startup
|
||||||
|
- Instrument connectivity via HAL
|
||||||
|
- Test execution through the framework
|
||||||
|
- Data persistence
|
||||||
|
- Results retrieval
|
||||||
|
|
||||||
|
These tests verify that all components work together correctly in a
|
||||||
|
complete workflow from server start to results analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import TestStatus
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
from py_dvt_ate.framework.runner import TestRunner
|
||||||
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
|
from py_dvt_ate.simulation.server import ServerConfig
|
||||||
|
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_tempco_characterization(simulation_server: ServerConfig) -> None:
|
||||||
|
"""End-to-end test: Run complete TempCo characterization workflow.
|
||||||
|
|
||||||
|
This test exercises the entire system:
|
||||||
|
1. Simulation server is running (from fixture)
|
||||||
|
2. Create instruments via HAL factory
|
||||||
|
3. Create test repository and runner
|
||||||
|
4. Execute TempCo test
|
||||||
|
5. Verify results are persisted
|
||||||
|
6. Verify measurements are stored
|
||||||
|
7. Retrieve and analyze results
|
||||||
|
|
||||||
|
This is the closest test to real-world usage, verifying that all
|
||||||
|
components integrate correctly.
|
||||||
|
"""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
# Step 1: Create instruments via HAL
|
||||||
|
instrument_config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
|
|
||||||
|
# Connect to instruments
|
||||||
|
instruments.chamber.transport.connect()
|
||||||
|
instruments.psu.transport.connect()
|
||||||
|
instruments.dmm.transport.connect()
|
||||||
|
|
||||||
|
# Verify instrument connectivity
|
||||||
|
idn = instruments.chamber.get_temperature() # Should not raise
|
||||||
|
assert isinstance(idn, float)
|
||||||
|
|
||||||
|
# Step 2: Create repository and test runner
|
||||||
|
db_path = Path(tmpdir) / "test.db"
|
||||||
|
repository = SQLiteRepository(str(db_path))
|
||||||
|
runner = TestRunner(repository)
|
||||||
|
|
||||||
|
# Step 3: Execute TempCo test with minimal config for speed
|
||||||
|
test = TempCoTest()
|
||||||
|
config = {
|
||||||
|
"temperatures": [0.0, 25.0, 50.0], # Reduced for test speed
|
||||||
|
"input_voltage": 5.0,
|
||||||
|
"load_current": 0.1,
|
||||||
|
"settle_time": 0.5, # Reduced for test speed
|
||||||
|
"num_samples": 3, # Reduced for test speed
|
||||||
|
"tempco_limit": 100.0, # Relaxed for test
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id = runner.run_test(
|
||||||
|
test=test,
|
||||||
|
instruments=instruments,
|
||||||
|
config=config,
|
||||||
|
operator="test_user",
|
||||||
|
description="E2E integration test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Verify run was created
|
||||||
|
assert run_id is not None
|
||||||
|
run = repository.get_run(run_id)
|
||||||
|
assert run.test_name == "tempco"
|
||||||
|
assert run.status in [TestStatus.PASSED, TestStatus.FAILED] # Either is valid
|
||||||
|
assert run.completed_at is not None
|
||||||
|
assert run.operator == "test_user"
|
||||||
|
|
||||||
|
# Step 5: Verify results were stored
|
||||||
|
results = repository.get_results(run_id)
|
||||||
|
assert len(results) > 0
|
||||||
|
|
||||||
|
# Should have TempCo result
|
||||||
|
tempco_result = next((r for r in results if r.parameter == "temp_co"), None)
|
||||||
|
assert tempco_result is not None
|
||||||
|
assert tempco_result.unit == "ppm/C"
|
||||||
|
assert tempco_result.lower_limit is not None
|
||||||
|
assert tempco_result.upper_limit is not None
|
||||||
|
|
||||||
|
# Step 6: Verify measurements were stored
|
||||||
|
measurements_df = repository.get_measurements_dataframe(run_id)
|
||||||
|
assert measurements_df is not None
|
||||||
|
assert not measurements_df.empty
|
||||||
|
|
||||||
|
# Should have v_out measurements
|
||||||
|
v_out_measurements = measurements_df[measurements_df["parameter"] == "v_out"]
|
||||||
|
assert len(v_out_measurements) >= 3 # At least one per temperature point
|
||||||
|
|
||||||
|
# Step 7: Verify data integrity
|
||||||
|
# All measurements should have valid values
|
||||||
|
assert (measurements_df["value"] > 0).all()
|
||||||
|
# All measurements should have units
|
||||||
|
assert measurements_df["unit"].notna().all()
|
||||||
|
# Timestamps should be monotonically increasing
|
||||||
|
assert measurements_df["timestamp"].is_monotonic_increasing
|
||||||
|
|
||||||
|
# Cleanup: close repository before tempdir cleanup (Windows file locking)
|
||||||
|
repository.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_server_lifecycle(simulation_server: ServerConfig) -> None:
|
||||||
|
"""Test simulation server lifecycle management.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Server starts successfully
|
||||||
|
- Physics engine is running
|
||||||
|
- Multiple instruments can connect
|
||||||
|
- Server can be stopped cleanly
|
||||||
|
"""
|
||||||
|
# Server is already running from fixture (in background thread)
|
||||||
|
# We verify it works by connecting instruments
|
||||||
|
|
||||||
|
# Multiple instruments should be able to connect
|
||||||
|
config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments1 = InstrumentFactory.create(config)
|
||||||
|
instruments2 = InstrumentFactory.create(config)
|
||||||
|
|
||||||
|
# Connect instruments
|
||||||
|
instruments1.chamber.transport.connect()
|
||||||
|
instruments2.chamber.transport.connect()
|
||||||
|
|
||||||
|
# Both should work independently
|
||||||
|
temp1 = instruments1.chamber.get_temperature()
|
||||||
|
temp2 = instruments2.chamber.get_temperature()
|
||||||
|
|
||||||
|
assert isinstance(temp1, float)
|
||||||
|
assert isinstance(temp2, float)
|
||||||
|
# Both should read similar values (same simulation)
|
||||||
|
assert abs(temp1 - temp2) < 1.0 # Within 1 degree
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_instrument_hal_abstraction(simulation_server: ServerConfig) -> None:
|
||||||
|
"""Test Hardware Abstraction Layer works correctly.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Instruments implement HAL interfaces
|
||||||
|
- Commands work through HAL
|
||||||
|
- State changes propagate through physics
|
||||||
|
"""
|
||||||
|
config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments = InstrumentFactory.create(config)
|
||||||
|
|
||||||
|
# Connect instruments
|
||||||
|
instruments.chamber.transport.connect()
|
||||||
|
instruments.psu.transport.connect()
|
||||||
|
instruments.dmm.transport.connect()
|
||||||
|
|
||||||
|
# Test thermal chamber HAL
|
||||||
|
instruments.chamber.set_temperature(30.0)
|
||||||
|
setpoint = instruments.chamber.get_setpoint()
|
||||||
|
assert setpoint == 30.0
|
||||||
|
|
||||||
|
# Test power supply HAL
|
||||||
|
instruments.psu.set_voltage(1, 5.0)
|
||||||
|
instruments.psu.set_current_limit(1, 0.5)
|
||||||
|
instruments.psu.enable_output(1, True)
|
||||||
|
|
||||||
|
voltage_setpoint = instruments.psu.get_voltage(1)
|
||||||
|
assert voltage_setpoint == 5.0
|
||||||
|
|
||||||
|
enabled = instruments.psu.is_output_enabled(1)
|
||||||
|
assert enabled is True
|
||||||
|
|
||||||
|
# Wait a moment for physics to update
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Measure voltage with DMM
|
||||||
|
measured_voltage = instruments.dmm.measure_dc_voltage()
|
||||||
|
assert isinstance(measured_voltage, float)
|
||||||
|
# Should be reading DUT output voltage (close to nominal)
|
||||||
|
assert 3.0 < measured_voltage < 3.6 # LDO output
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_multiple_test_runs(simulation_server: ServerConfig) -> None:
|
||||||
|
"""Test running multiple tests sequentially.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Multiple tests can be run in sequence
|
||||||
|
- Each test gets its own run ID
|
||||||
|
- All results are stored correctly
|
||||||
|
- Repository handles multiple runs
|
||||||
|
"""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments = InstrumentFactory.create(config)
|
||||||
|
|
||||||
|
# Connect instruments
|
||||||
|
instruments.chamber.transport.connect()
|
||||||
|
instruments.psu.transport.connect()
|
||||||
|
instruments.dmm.transport.connect()
|
||||||
|
|
||||||
|
db_path = Path(tmpdir) / "test.db"
|
||||||
|
repository = SQLiteRepository(str(db_path))
|
||||||
|
runner = TestRunner(repository)
|
||||||
|
|
||||||
|
# Run same test twice with different configs
|
||||||
|
test = TempCoTest()
|
||||||
|
|
||||||
|
config1 = {
|
||||||
|
"temperatures": [0.0, 25.0], # Need at least 2 points for TempCo
|
||||||
|
"input_voltage": 5.0,
|
||||||
|
"load_current": 0.1,
|
||||||
|
"settle_time": 0.5,
|
||||||
|
"num_samples": 3,
|
||||||
|
"tempco_limit": 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
config2 = {
|
||||||
|
"temperatures": [25.0, 50.0], # Need at least 2 points for TempCo
|
||||||
|
"input_voltage": 3.3,
|
||||||
|
"load_current": 0.05,
|
||||||
|
"settle_time": 0.5,
|
||||||
|
"num_samples": 3,
|
||||||
|
"tempco_limit": 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id1 = runner.run_test(test, instruments, config1, operator="test_user_1")
|
||||||
|
run_id2 = runner.run_test(test, instruments, config2, operator="test_user_2")
|
||||||
|
|
||||||
|
# Both runs should complete
|
||||||
|
assert run_id1 != run_id2
|
||||||
|
|
||||||
|
run1 = repository.get_run(run_id1)
|
||||||
|
run2 = repository.get_run(run_id2)
|
||||||
|
|
||||||
|
assert run1.operator == "test_user_1"
|
||||||
|
assert run2.operator == "test_user_2"
|
||||||
|
|
||||||
|
# Both should have results
|
||||||
|
results1 = repository.get_results(run_id1)
|
||||||
|
results2 = repository.get_results(run_id2)
|
||||||
|
|
||||||
|
assert len(results1) > 0
|
||||||
|
assert len(results2) > 0
|
||||||
|
|
||||||
|
# Verify get_all_runs works
|
||||||
|
all_runs = repository.get_all_runs()
|
||||||
|
assert len(all_runs) >= 2
|
||||||
|
assert any(r.id == str(run_id1) for r in all_runs)
|
||||||
|
assert any(r.id == str(run_id2) for r in all_runs)
|
||||||
|
|
||||||
|
# Cleanup: close repository before tempdir cleanup (Windows file locking)
|
||||||
|
repository.close()
|
||||||
291
tests/integration/test_report_generation.py
Normal file
291
tests/integration/test_report_generation.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""Integration tests for report generation.
|
||||||
|
|
||||||
|
Tests the full report generation pipeline from test run to PDF output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import Measurement, TestStatus
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportGenerationIntegration:
|
||||||
|
"""Integration tests for the report generation pipeline."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir(self) -> Path:
|
||||||
|
"""Create a temporary directory for test files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
yield Path(tmpdir)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(self, temp_dir: Path) -> SQLiteRepository:
|
||||||
|
"""Create a test repository with sample data."""
|
||||||
|
db_path = temp_dir / "test.db"
|
||||||
|
measurements_dir = temp_dir / "measurements"
|
||||||
|
|
||||||
|
repo = SQLiteRepository(db_path, measurements_dir)
|
||||||
|
|
||||||
|
# Create a test run
|
||||||
|
test_config = {
|
||||||
|
"temperatures": [-40.0, 25.0, 85.0],
|
||||||
|
"input_voltage": 5.0,
|
||||||
|
"load_current": 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id = repo.create_run(
|
||||||
|
test_name="tempco",
|
||||||
|
config=test_config,
|
||||||
|
operator="test_operator",
|
||||||
|
description="Integration test run",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add some results
|
||||||
|
repo.save_result(
|
||||||
|
run_id=run_id,
|
||||||
|
parameter="tempco",
|
||||||
|
value=48.5,
|
||||||
|
unit="ppm/C",
|
||||||
|
lower_limit=None,
|
||||||
|
upper_limit=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.save_result(
|
||||||
|
run_id=run_id,
|
||||||
|
parameter="output_voltage_m40c",
|
||||||
|
value=3.2965,
|
||||||
|
unit="V",
|
||||||
|
lower_limit=3.2,
|
||||||
|
upper_limit=3.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.save_result(
|
||||||
|
run_id=run_id,
|
||||||
|
parameter="output_voltage_25c",
|
||||||
|
value=3.3000,
|
||||||
|
unit="V",
|
||||||
|
lower_limit=3.2,
|
||||||
|
upper_limit=3.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.save_result(
|
||||||
|
run_id=run_id,
|
||||||
|
parameter="output_voltage_85c",
|
||||||
|
value=3.2901,
|
||||||
|
unit="V",
|
||||||
|
lower_limit=3.2,
|
||||||
|
upper_limit=3.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add measurements
|
||||||
|
measurements = []
|
||||||
|
temperatures = [-40.0, 0.0, 25.0, 50.0, 85.0]
|
||||||
|
voltages = [3.2965, 3.2985, 3.3000, 3.2960, 3.2901]
|
||||||
|
|
||||||
|
for i, (temp, voltage) in enumerate(zip(temperatures, voltages, strict=False)):
|
||||||
|
measurements.append(
|
||||||
|
Measurement(
|
||||||
|
timestamp=float(i * 60),
|
||||||
|
parameter="output_voltage",
|
||||||
|
value=voltage,
|
||||||
|
unit="V",
|
||||||
|
temperature=temp,
|
||||||
|
input_voltage=5.0,
|
||||||
|
load_current=0.1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.save_measurements(run_id, measurements)
|
||||||
|
|
||||||
|
# Complete the run
|
||||||
|
repo.complete_run(run_id, TestStatus.PASSED)
|
||||||
|
|
||||||
|
return repo
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def run_id(self, repository: SQLiteRepository) -> UUID:
|
||||||
|
"""Get the test run ID from the repository."""
|
||||||
|
runs = repository.get_all_runs()
|
||||||
|
return UUID(runs[0].id)
|
||||||
|
|
||||||
|
def test_full_report_generation(
|
||||||
|
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test complete report generation pipeline."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
|
||||||
|
# Create report config
|
||||||
|
config = ReportConfig(
|
||||||
|
company_name="Test Company Ltd",
|
||||||
|
include_charts=True,
|
||||||
|
chart_dpi=100, # Lower for faster tests
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create generator
|
||||||
|
reports_dir = temp_dir / "reports"
|
||||||
|
generator = ReportGenerator(
|
||||||
|
repository=repository,
|
||||||
|
config=config,
|
||||||
|
reports_dir=reports_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate report
|
||||||
|
pdf_path = generator.generate(run_id)
|
||||||
|
|
||||||
|
# Verify PDF was created
|
||||||
|
assert pdf_path.exists()
|
||||||
|
assert pdf_path.suffix == ".pdf"
|
||||||
|
assert pdf_path.stat().st_size > 1000 # Should be non-trivial size
|
||||||
|
|
||||||
|
# Verify it's in the reports directory
|
||||||
|
assert pdf_path.parent == reports_dir
|
||||||
|
|
||||||
|
def test_report_generation_custom_path(
|
||||||
|
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test report generation with custom output path."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
|
||||||
|
config = ReportConfig(include_charts=False) # No charts for faster test
|
||||||
|
|
||||||
|
generator = ReportGenerator(
|
||||||
|
repository=repository,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate to custom path
|
||||||
|
custom_path = temp_dir / "custom_report.pdf"
|
||||||
|
pdf_path = generator.generate(run_id, output_path=custom_path)
|
||||||
|
|
||||||
|
assert pdf_path == custom_path
|
||||||
|
assert pdf_path.exists()
|
||||||
|
|
||||||
|
def test_report_generation_as_bytes(
|
||||||
|
self, repository: SQLiteRepository, run_id: UUID
|
||||||
|
) -> None:
|
||||||
|
"""Test generating report as bytes."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
|
||||||
|
config = ReportConfig(include_charts=False)
|
||||||
|
|
||||||
|
generator = ReportGenerator(
|
||||||
|
repository=repository,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate as bytes
|
||||||
|
pdf_bytes = generator.generate_bytes(run_id)
|
||||||
|
|
||||||
|
# Verify it's a valid PDF
|
||||||
|
assert isinstance(pdf_bytes, bytes)
|
||||||
|
assert pdf_bytes.startswith(b"%PDF") # PDF magic bytes
|
||||||
|
assert len(pdf_bytes) > 1000
|
||||||
|
|
||||||
|
def test_report_includes_all_data(
|
||||||
|
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that generated report includes all expected data."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
|
||||||
|
|
||||||
|
config = ReportConfig(
|
||||||
|
company_name="Test Company",
|
||||||
|
include_charts=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
generator = ReportGenerator(
|
||||||
|
repository=repository,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get HTML (intermediate step) to check content
|
||||||
|
data = generator._gather_data(run_id)
|
||||||
|
html_renderer = HTMLRenderer()
|
||||||
|
html = html_renderer.render(data)
|
||||||
|
|
||||||
|
# Check for expected content
|
||||||
|
assert "tempco" in html
|
||||||
|
assert "Test Company" in html
|
||||||
|
assert "48.500000" in html # tempco value
|
||||||
|
assert "3.300000" in html # output voltage
|
||||||
|
assert "test_operator" in html
|
||||||
|
assert "Integration test run" in html
|
||||||
|
assert "PASS" in html
|
||||||
|
|
||||||
|
def test_report_with_failed_results(
|
||||||
|
self, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test report generation with failed test results."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
|
||||||
|
# Create repository with a failed test
|
||||||
|
db_path = temp_dir / "failed_test.db"
|
||||||
|
repo = SQLiteRepository(db_path, temp_dir / "measurements")
|
||||||
|
|
||||||
|
run_id = repo.create_run(
|
||||||
|
test_name="failed_test",
|
||||||
|
config={},
|
||||||
|
operator="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add failing result
|
||||||
|
repo.save_result(
|
||||||
|
run_id=run_id,
|
||||||
|
parameter="test_param",
|
||||||
|
value=150.0, # Exceeds limit
|
||||||
|
unit="X",
|
||||||
|
lower_limit=0.0,
|
||||||
|
upper_limit=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.complete_run(run_id, TestStatus.FAILED)
|
||||||
|
|
||||||
|
# Generate report
|
||||||
|
config = ReportConfig(include_charts=False)
|
||||||
|
generator = ReportGenerator(repository=repo, config=config)
|
||||||
|
|
||||||
|
pdf_bytes = generator.generate_bytes(run_id)
|
||||||
|
|
||||||
|
# Should still generate
|
||||||
|
assert pdf_bytes.startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_report_generation_invalid_run_id(
|
||||||
|
self, repository: SQLiteRepository, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that invalid run ID raises appropriate error."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerationError, ReportGenerator
|
||||||
|
|
||||||
|
config = ReportConfig()
|
||||||
|
generator = ReportGenerator(repository=repository, config=config)
|
||||||
|
|
||||||
|
invalid_id = uuid4()
|
||||||
|
|
||||||
|
with pytest.raises(ReportGenerationError):
|
||||||
|
generator.generate(invalid_id)
|
||||||
|
|
||||||
|
def test_report_charts_generation(
|
||||||
|
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test that charts are generated when enabled."""
|
||||||
|
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
|
||||||
|
|
||||||
|
config = ReportConfig(include_charts=True, chart_dpi=72)
|
||||||
|
|
||||||
|
generator = ReportGenerator(
|
||||||
|
repository=repository,
|
||||||
|
config=config,
|
||||||
|
reports_dir=temp_dir / "reports",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gather data and check charts
|
||||||
|
data = generator._gather_data(run_id)
|
||||||
|
|
||||||
|
# Should have at least one chart (results bar chart)
|
||||||
|
assert len(data.charts) >= 1
|
||||||
|
|
||||||
|
# Voltage vs temperature chart should be present (we have voltage measurements)
|
||||||
|
assert "Voltage vs Temperature" in data.charts or "Results Summary" in data.charts
|
||||||
@@ -73,6 +73,8 @@ class TestInstrumentServer:
|
|||||||
# Set temperature setpoint
|
# Set temperature setpoint
|
||||||
writer.write(b"TEMP:SETPOINT 85.0\n")
|
writer.write(b"TEMP:SETPOINT 85.0\n")
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
# Small delay to ensure server processes command before next one
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
# Query setpoint
|
# Query setpoint
|
||||||
writer.write(b"TEMP:SETPOINT?\n")
|
writer.write(b"TEMP:SETPOINT?\n")
|
||||||
@@ -212,6 +214,7 @@ class TestSimulationServer:
|
|||||||
psu_r, psu_w = await asyncio.open_connection("127.0.0.1", 16201)
|
psu_r, psu_w = await asyncio.open_connection("127.0.0.1", 16201)
|
||||||
psu_w.write(b"VOLT 5.0\n")
|
psu_w.write(b"VOLT 5.0\n")
|
||||||
await psu_w.drain()
|
await psu_w.drain()
|
||||||
|
await asyncio.sleep(0.01) # Allow server to process
|
||||||
psu_w.write(b"OUTP ON\n")
|
psu_w.write(b"OUTP ON\n")
|
||||||
await psu_w.drain()
|
await psu_w.drain()
|
||||||
|
|
||||||
|
|||||||
@@ -7,54 +7,32 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from py_dvt_ate.data.models import TestStatus
|
from py_dvt_ate.data.models import TestStatus
|
||||||
from py_dvt_ate.data.repository import SQLiteRepository
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
from py_dvt_ate.framework.context import TestContext
|
from py_dvt_ate.framework.context import TestContext
|
||||||
from py_dvt_ate.framework.logger import TestLogger
|
from py_dvt_ate.framework.logger import TestLogger
|
||||||
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
from py_dvt_ate.simulation.server import ServerConfig
|
||||||
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
|
||||||
class TestTempCoIntegration:
|
class TestTempCoIntegration:
|
||||||
"""Integration tests for TempCo test with simulator."""
|
"""Integration tests for TempCo test with simulator."""
|
||||||
|
|
||||||
async def test_tempco_runs_successfully(self, tmp_path: Path) -> None:
|
def test_tempco_runs_successfully(
|
||||||
|
self, tmp_path: Path, simulation_server: ServerConfig
|
||||||
|
) -> None:
|
||||||
"""Test TempCo test runs end-to-end with simulator."""
|
"""Test TempCo test runs end-to-end with simulator."""
|
||||||
# Start simulation server
|
|
||||||
server_config = ServerConfig(
|
|
||||||
host="127.0.0.1",
|
|
||||||
chamber_port=17000,
|
|
||||||
psu_port=17001,
|
|
||||||
dmm_port=17002,
|
|
||||||
physics_rate_hz=100.0,
|
|
||||||
)
|
|
||||||
server = SimulationServer(server_config)
|
|
||||||
await server.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create instrument set connected to simulator
|
# Create instrument set connected to simulator
|
||||||
instrument_config = InstrumentConfig(
|
instrument_config = InstrumentConfig(
|
||||||
backend="simulator",
|
backend="simulator",
|
||||||
simulator_host="127.0.0.1",
|
simulator_host=simulation_server.host,
|
||||||
chamber_port=17000,
|
chamber_port=simulation_server.chamber_port,
|
||||||
psu_port=17001,
|
psu_port=simulation_server.psu_port,
|
||||||
dmm_port=17002,
|
dmm_port=simulation_server.dmm_port,
|
||||||
)
|
)
|
||||||
instruments = InstrumentFactory.create(instrument_config)
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
|
|
||||||
# Connect to instruments
|
|
||||||
instruments.chamber.connect()
|
|
||||||
instruments.psu.connect()
|
|
||||||
instruments.dmm.connect()
|
|
||||||
|
|
||||||
# Configure instruments
|
|
||||||
instruments.chamber.set_ramp_rate(10.0) # Fast ramp for testing
|
|
||||||
instruments.psu.enable_output(1, False) # Ensure off initially
|
|
||||||
|
|
||||||
# Create test repository
|
# Create test repository
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
repository = SQLiteRepository(db_path)
|
repository = SQLiteRepository(db_path)
|
||||||
@@ -63,10 +41,10 @@ class TestTempCoIntegration:
|
|||||||
run_id = repository.create_run(
|
run_id = repository.create_run(
|
||||||
test_name="tempco",
|
test_name="tempco",
|
||||||
config={
|
config={
|
||||||
"temperatures": [0.0, 25.0, 50.0], # Reduced for faster test
|
"temperatures": [23.0, 25.0, 27.0], # Close to start temp for fast settling
|
||||||
"input_voltage": 5.0,
|
"input_voltage": 5.0,
|
||||||
"load_current": 0.1,
|
"load_current": 0.1,
|
||||||
"settle_time": 0.5, # Reduced for faster test
|
"settle_time": 0.2, # Short since temps close to start
|
||||||
"num_samples": 3, # Reduced for faster test
|
"num_samples": 3, # Reduced for faster test
|
||||||
"tempco_limit": 100.0, # Relaxed for testing
|
"tempco_limit": 100.0, # Relaxed for testing
|
||||||
},
|
},
|
||||||
@@ -82,21 +60,31 @@ class TestTempCoIntegration:
|
|||||||
instruments=instruments,
|
instruments=instruments,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
config={
|
config={
|
||||||
"temperatures": [0.0, 25.0, 50.0],
|
"temperatures": [23.0, 25.0, 27.0],
|
||||||
"input_voltage": 5.0,
|
"input_voltage": 5.0,
|
||||||
"load_current": 0.1,
|
"load_current": 0.1,
|
||||||
"settle_time": 0.5,
|
"settle_time": 0.2,
|
||||||
"num_samples": 3,
|
"num_samples": 3,
|
||||||
"tempco_limit": 100.0,
|
"tempco_limit": 100.0,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create and execute test
|
# Create test
|
||||||
test = TempCoTest()
|
test = TempCoTest()
|
||||||
assert test.name == "tempco"
|
assert test.name == "tempco"
|
||||||
assert test.description == "Output voltage temperature coefficient"
|
assert test.description == "Output voltage temperature coefficient"
|
||||||
|
|
||||||
# Run test (this is synchronous, but simulation runs async in background)
|
# Connect to instruments
|
||||||
|
instruments.chamber.connect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.connect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.connect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Configure instruments
|
||||||
|
instruments.chamber.set_ramp_rate(10.0) # Fast ramp for testing
|
||||||
|
instruments.psu.enable_output(1, False) # Ensure off initially
|
||||||
|
|
||||||
|
# Run test
|
||||||
status = test.execute(context)
|
status = test.execute(context)
|
||||||
|
|
||||||
# Verify test completed
|
# Verify test completed
|
||||||
@@ -115,7 +103,7 @@ class TestTempCoIntegration:
|
|||||||
# Find TempCo result
|
# Find TempCo result
|
||||||
tempco_result = next(r for r in results if r.parameter == "temp_co")
|
tempco_result = next(r for r in results if r.parameter == "temp_co")
|
||||||
assert tempco_result is not None
|
assert tempco_result is not None
|
||||||
assert tempco_result.unit == "ppm/°C"
|
assert tempco_result.unit == "ppm/C"
|
||||||
assert tempco_result.lower_limit == -100.0
|
assert tempco_result.lower_limit == -100.0
|
||||||
assert tempco_result.upper_limit == 100.0
|
assert tempco_result.upper_limit == 100.0
|
||||||
|
|
||||||
@@ -134,36 +122,25 @@ class TestTempCoIntegration:
|
|||||||
assert len(temps_recorded) >= 3
|
assert len(temps_recorded) >= 3
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
# Disconnect from instruments
|
||||||
|
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
async def test_tempco_with_minimal_config(self, tmp_path: Path) -> None:
|
def test_tempco_with_minimal_config(
|
||||||
|
self, tmp_path: Path, simulation_server: ServerConfig
|
||||||
|
) -> None:
|
||||||
"""Test TempCo uses default configuration when not specified."""
|
"""Test TempCo uses default configuration when not specified."""
|
||||||
# Start simulation server
|
|
||||||
server_config = ServerConfig(
|
|
||||||
host="127.0.0.1",
|
|
||||||
chamber_port=17100,
|
|
||||||
psu_port=17101,
|
|
||||||
dmm_port=17102,
|
|
||||||
)
|
|
||||||
server = SimulationServer(server_config)
|
|
||||||
await server.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create instrument set
|
# Create instrument set
|
||||||
instrument_config = InstrumentConfig(
|
instrument_config = InstrumentConfig(
|
||||||
backend="simulator",
|
backend="simulator",
|
||||||
simulator_host="127.0.0.1",
|
simulator_host=simulation_server.host,
|
||||||
chamber_port=17100,
|
chamber_port=simulation_server.chamber_port,
|
||||||
psu_port=17101,
|
psu_port=simulation_server.psu_port,
|
||||||
dmm_port=17102,
|
dmm_port=simulation_server.dmm_port,
|
||||||
)
|
)
|
||||||
instruments = InstrumentFactory.create(instrument_config)
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
|
|
||||||
# Connect to instruments
|
|
||||||
instruments.chamber.connect()
|
|
||||||
instruments.psu.connect()
|
|
||||||
instruments.dmm.connect()
|
|
||||||
|
|
||||||
# Create repository
|
# Create repository
|
||||||
db_path = tmp_path / "test_minimal.db"
|
db_path = tmp_path / "test_minimal.db"
|
||||||
repository = SQLiteRepository(db_path)
|
repository = SQLiteRepository(db_path)
|
||||||
@@ -180,7 +157,7 @@ class TestTempCoIntegration:
|
|||||||
logger=logger,
|
logger=logger,
|
||||||
config={
|
config={
|
||||||
# Override temperatures for faster test
|
# Override temperatures for faster test
|
||||||
"temperatures": [25.0, 50.0],
|
"temperatures": [24.0, 26.0],
|
||||||
"settle_time": 0.2,
|
"settle_time": 0.2,
|
||||||
"num_samples": 2,
|
"num_samples": 2,
|
||||||
},
|
},
|
||||||
@@ -188,6 +165,14 @@ class TestTempCoIntegration:
|
|||||||
|
|
||||||
# Execute test
|
# Execute test
|
||||||
test = TempCoTest()
|
test = TempCoTest()
|
||||||
|
|
||||||
|
# Connect to instruments
|
||||||
|
instruments.chamber.connect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.connect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.connect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run test
|
||||||
status = test.execute(context)
|
status = test.execute(context)
|
||||||
|
|
||||||
# Should complete without error
|
# Should complete without error
|
||||||
@@ -201,36 +186,25 @@ class TestTempCoIntegration:
|
|||||||
assert len(results) >= 1
|
assert len(results) >= 1
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
# Disconnect from instruments
|
||||||
|
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
async def test_tempco_handles_errors_gracefully(self, tmp_path: Path) -> None:
|
def test_tempco_handles_errors_gracefully(
|
||||||
|
self, tmp_path: Path, simulation_server: ServerConfig
|
||||||
|
) -> None:
|
||||||
"""Test TempCo returns ERROR status when instruments fail."""
|
"""Test TempCo returns ERROR status when instruments fail."""
|
||||||
# Start simulation server
|
|
||||||
server_config = ServerConfig(
|
|
||||||
host="127.0.0.1",
|
|
||||||
chamber_port=17200,
|
|
||||||
psu_port=17201,
|
|
||||||
dmm_port=17202,
|
|
||||||
)
|
|
||||||
server = SimulationServer(server_config)
|
|
||||||
await server.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create instrument set
|
# Create instrument set
|
||||||
instrument_config = InstrumentConfig(
|
instrument_config = InstrumentConfig(
|
||||||
backend="simulator",
|
backend="simulator",
|
||||||
simulator_host="127.0.0.1",
|
simulator_host=simulation_server.host,
|
||||||
chamber_port=17200,
|
chamber_port=simulation_server.chamber_port,
|
||||||
psu_port=17201,
|
psu_port=simulation_server.psu_port,
|
||||||
dmm_port=17202,
|
dmm_port=simulation_server.dmm_port,
|
||||||
)
|
)
|
||||||
instruments = InstrumentFactory.create(instrument_config)
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
|
|
||||||
# Connect to instruments
|
|
||||||
instruments.chamber.connect()
|
|
||||||
instruments.psu.connect()
|
|
||||||
instruments.dmm.connect()
|
|
||||||
|
|
||||||
# Create repository
|
# Create repository
|
||||||
db_path = tmp_path / "test_error.db"
|
db_path = tmp_path / "test_error.db"
|
||||||
repository = SQLiteRepository(db_path)
|
repository = SQLiteRepository(db_path)
|
||||||
@@ -251,6 +225,12 @@ class TestTempCoIntegration:
|
|||||||
# Execute test
|
# Execute test
|
||||||
test = TempCoTest()
|
test = TempCoTest()
|
||||||
|
|
||||||
|
# Connect to instruments
|
||||||
|
instruments.chamber.connect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.connect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.connect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
try:
|
||||||
# Should handle gracefully (may return FAILED or ERROR)
|
# Should handle gracefully (may return FAILED or ERROR)
|
||||||
# The test should not raise an unhandled exception
|
# The test should not raise an unhandled exception
|
||||||
try:
|
try:
|
||||||
@@ -264,4 +244,7 @@ class TestTempCoIntegration:
|
|||||||
logger.flush()
|
logger.flush()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
# Disconnect from instruments
|
||||||
|
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||||
|
|||||||
1
tests/unit/reporting/__init__.py
Normal file
1
tests/unit/reporting/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests for reporting module."""
|
||||||
220
tests/unit/reporting/test_chart_generator.py
Normal file
220
tests/unit/reporting/test_chart_generator.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""Unit tests for chart generator."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import TestResult, TestRun, TestStatus
|
||||||
|
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
|
||||||
|
from py_dvt_ate.reporting.exceptions import ChartGenerationError
|
||||||
|
|
||||||
|
|
||||||
|
class TestChartGenerator:
|
||||||
|
"""Tests for ChartGenerator class."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def generator(self) -> ChartGenerator:
|
||||||
|
"""Create a chart generator instance."""
|
||||||
|
return ChartGenerator(dpi=100) # Lower DPI for faster tests
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_run(self) -> TestRun:
|
||||||
|
"""Create a sample test run."""
|
||||||
|
return TestRun(
|
||||||
|
id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
test_name="tempco",
|
||||||
|
started_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||||
|
status=TestStatus.PASSED,
|
||||||
|
config_json="{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_results(self) -> list[TestResult]:
|
||||||
|
"""Create sample test results."""
|
||||||
|
return [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="tempco",
|
||||||
|
value=45.0,
|
||||||
|
unit="ppm/C",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
),
|
||||||
|
TestResult(
|
||||||
|
id="result-2",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="output_voltage_25c",
|
||||||
|
value=3.3001,
|
||||||
|
unit="V",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 33, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def voltage_measurements(self) -> pd.DataFrame:
|
||||||
|
"""Create sample voltage measurements."""
|
||||||
|
return pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": [0.0, 100.0, 200.0, 300.0, 400.0],
|
||||||
|
"parameter": [
|
||||||
|
"output_voltage",
|
||||||
|
"output_voltage",
|
||||||
|
"output_voltage",
|
||||||
|
"output_voltage",
|
||||||
|
"output_voltage",
|
||||||
|
],
|
||||||
|
"value": [3.300, 3.298, 3.295, 3.290, 3.285],
|
||||||
|
"unit": ["V", "V", "V", "V", "V"],
|
||||||
|
"temperature": [-40.0, 0.0, 25.0, 50.0, 85.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generator_initialisation(self) -> None:
|
||||||
|
"""Test chart generator initialisation."""
|
||||||
|
generator = ChartGenerator(dpi=200)
|
||||||
|
|
||||||
|
assert generator.dpi == 200
|
||||||
|
|
||||||
|
def test_generate_voltage_vs_temperature(
|
||||||
|
self, generator: ChartGenerator, voltage_measurements: pd.DataFrame
|
||||||
|
) -> None:
|
||||||
|
"""Test generating voltage vs temperature chart."""
|
||||||
|
chart_b64 = generator.generate_voltage_vs_temperature(voltage_measurements)
|
||||||
|
|
||||||
|
# Should be valid base64
|
||||||
|
assert isinstance(chart_b64, str)
|
||||||
|
assert len(chart_b64) > 100 # Should have meaningful content
|
||||||
|
|
||||||
|
# Should decode to PNG image
|
||||||
|
decoded = base64.b64decode(chart_b64)
|
||||||
|
assert decoded[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes
|
||||||
|
|
||||||
|
def test_generate_voltage_vs_temperature_no_data(
|
||||||
|
self, generator: ChartGenerator
|
||||||
|
) -> None:
|
||||||
|
"""Test that error is raised with no voltage data."""
|
||||||
|
empty_df = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": [0.0],
|
||||||
|
"parameter": ["other_param"],
|
||||||
|
"value": [1.0],
|
||||||
|
"unit": ["X"],
|
||||||
|
"temperature": [25.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ChartGenerationError):
|
||||||
|
generator.generate_voltage_vs_temperature(empty_df)
|
||||||
|
|
||||||
|
def test_generate_results_bar_chart(
|
||||||
|
self, generator: ChartGenerator, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test generating results bar chart."""
|
||||||
|
chart_b64 = generator.generate_results_bar_chart(sample_results)
|
||||||
|
|
||||||
|
# Should be valid base64
|
||||||
|
assert isinstance(chart_b64, str)
|
||||||
|
assert len(chart_b64) > 100
|
||||||
|
|
||||||
|
# Should decode to PNG image
|
||||||
|
decoded = base64.b64decode(chart_b64)
|
||||||
|
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
def test_generate_results_bar_chart_empty(
|
||||||
|
self, generator: ChartGenerator
|
||||||
|
) -> None:
|
||||||
|
"""Test that error is raised with no results."""
|
||||||
|
with pytest.raises(ChartGenerationError):
|
||||||
|
generator.generate_results_bar_chart([])
|
||||||
|
|
||||||
|
def test_generate_all_with_measurements(
|
||||||
|
self,
|
||||||
|
generator: ChartGenerator,
|
||||||
|
sample_run: TestRun,
|
||||||
|
sample_results: list[TestResult],
|
||||||
|
voltage_measurements: pd.DataFrame,
|
||||||
|
) -> None:
|
||||||
|
"""Test generate_all produces expected charts."""
|
||||||
|
charts = generator.generate_all(sample_run, sample_results, voltage_measurements)
|
||||||
|
|
||||||
|
# Should have both chart types
|
||||||
|
assert "Voltage vs Temperature" in charts
|
||||||
|
assert "Results Summary" in charts
|
||||||
|
|
||||||
|
# All should be valid base64
|
||||||
|
for name, b64 in charts.items():
|
||||||
|
assert isinstance(b64, str)
|
||||||
|
decoded = base64.b64decode(b64)
|
||||||
|
assert decoded[:8] == b"\x89PNG\r\n\x1a\n", f"Chart {name} is not valid PNG"
|
||||||
|
|
||||||
|
def test_generate_all_no_measurements(
|
||||||
|
self,
|
||||||
|
generator: ChartGenerator,
|
||||||
|
sample_run: TestRun,
|
||||||
|
sample_results: list[TestResult],
|
||||||
|
) -> None:
|
||||||
|
"""Test generate_all with no measurements."""
|
||||||
|
charts = generator.generate_all(sample_run, sample_results, None)
|
||||||
|
|
||||||
|
# Should only have results chart
|
||||||
|
assert "Voltage vs Temperature" not in charts
|
||||||
|
assert "Results Summary" in charts
|
||||||
|
|
||||||
|
def test_generate_all_no_results(
|
||||||
|
self,
|
||||||
|
generator: ChartGenerator,
|
||||||
|
sample_run: TestRun,
|
||||||
|
voltage_measurements: pd.DataFrame,
|
||||||
|
) -> None:
|
||||||
|
"""Test generate_all with no results."""
|
||||||
|
charts = generator.generate_all(sample_run, [], voltage_measurements)
|
||||||
|
|
||||||
|
# Should only have voltage chart
|
||||||
|
assert "Voltage vs Temperature" in charts
|
||||||
|
assert "Results Summary" not in charts
|
||||||
|
|
||||||
|
def test_generate_all_empty(
|
||||||
|
self, generator: ChartGenerator, sample_run: TestRun
|
||||||
|
) -> None:
|
||||||
|
"""Test generate_all with no data."""
|
||||||
|
charts = generator.generate_all(sample_run, [], None)
|
||||||
|
|
||||||
|
# Should be empty
|
||||||
|
assert charts == {}
|
||||||
|
|
||||||
|
def test_matplotlib_lazy_load(self) -> None:
|
||||||
|
"""Test that matplotlib is lazy loaded."""
|
||||||
|
generator = ChartGenerator()
|
||||||
|
|
||||||
|
# _plt should be None before first use
|
||||||
|
assert generator._plt is None
|
||||||
|
|
||||||
|
# After calling _get_matplotlib, it should be loaded
|
||||||
|
plt, mpl = generator._get_matplotlib()
|
||||||
|
|
||||||
|
assert generator._plt is not None
|
||||||
|
assert plt is not None
|
||||||
|
|
||||||
|
def test_dpi_affects_output_size(self) -> None:
|
||||||
|
"""Test that higher DPI produces larger output."""
|
||||||
|
low_dpi = ChartGenerator(dpi=50)
|
||||||
|
high_dpi = ChartGenerator(dpi=150)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="test",
|
||||||
|
value=1.0,
|
||||||
|
unit="X",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
low_chart = low_dpi.generate_results_bar_chart(results)
|
||||||
|
high_chart = high_dpi.generate_results_bar_chart(results)
|
||||||
|
|
||||||
|
# Higher DPI should produce larger image
|
||||||
|
assert len(high_chart) > len(low_chart)
|
||||||
221
tests/unit/reporting/test_html_renderer.py
Normal file
221
tests/unit/reporting/test_html_renderer.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""Unit tests for HTML renderer."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import TestResult, TestRun, TestStatus
|
||||||
|
from py_dvt_ate.reporting.models import ReportConfig, ReportData
|
||||||
|
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class TestHTMLRenderer:
|
||||||
|
"""Tests for HTMLRenderer class."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def renderer(self) -> HTMLRenderer:
|
||||||
|
"""Create an HTML renderer instance."""
|
||||||
|
return HTMLRenderer()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_run(self) -> TestRun:
|
||||||
|
"""Create a sample test run."""
|
||||||
|
return TestRun(
|
||||||
|
id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
test_name="tempco",
|
||||||
|
started_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||||
|
completed_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
status=TestStatus.PASSED,
|
||||||
|
config_json='{"temperatures": [-40, 25, 85], "input_voltage": 5.0}',
|
||||||
|
operator="test_user",
|
||||||
|
description="Temperature coefficient characterisation test",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_results(self) -> list[TestResult]:
|
||||||
|
"""Create sample test results."""
|
||||||
|
return [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="tempco",
|
||||||
|
value=45.0,
|
||||||
|
unit="ppm/C",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
lower_limit=None,
|
||||||
|
upper_limit=100.0,
|
||||||
|
),
|
||||||
|
TestResult(
|
||||||
|
id="result-2",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="output_voltage_25c",
|
||||||
|
value=3.3001,
|
||||||
|
unit="V",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 33, 0),
|
||||||
|
lower_limit=3.2,
|
||||||
|
upper_limit=3.4,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_report_data(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> ReportData:
|
||||||
|
"""Create sample report data."""
|
||||||
|
return ReportData(
|
||||||
|
run=sample_run,
|
||||||
|
results=sample_results,
|
||||||
|
config=ReportConfig(company_name="Test Company"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_render_produces_html(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render produces valid HTML output."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
assert isinstance(html, str)
|
||||||
|
assert html.startswith("<!DOCTYPE html>")
|
||||||
|
assert "</html>" in html
|
||||||
|
|
||||||
|
def test_render_includes_title(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes the test name in title."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
assert "<title>" in html
|
||||||
|
assert "tempco" in html
|
||||||
|
|
||||||
|
def test_render_includes_company_name(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes the company name."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
assert "Test Company" in html
|
||||||
|
|
||||||
|
def test_render_includes_results_table(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes results in a table."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
# Check for parameter names in output
|
||||||
|
assert "tempco" in html
|
||||||
|
assert "output_voltage_25c" in html
|
||||||
|
|
||||||
|
# Check for values
|
||||||
|
assert "45.000000" in html
|
||||||
|
assert "3.300100" in html
|
||||||
|
|
||||||
|
def test_render_includes_pass_status(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render shows pass status badges."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
# Check for PASS badge (results should pass)
|
||||||
|
assert "PASS" in html
|
||||||
|
|
||||||
|
def test_render_includes_run_metadata(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes run metadata."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
assert "12345678-1234-1234-1234-123456789abc" in html
|
||||||
|
assert "test_user" in html
|
||||||
|
assert "Temperature coefficient characterisation test" in html
|
||||||
|
|
||||||
|
def test_render_includes_css(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes CSS styles."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
# Check for some CSS from styles.css
|
||||||
|
assert "<style>" in html
|
||||||
|
assert "@page" in html or "font-family" in html
|
||||||
|
|
||||||
|
def test_render_includes_configuration(
|
||||||
|
self, renderer: HTMLRenderer, sample_report_data: ReportData
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes test configuration."""
|
||||||
|
html = renderer.render(sample_report_data)
|
||||||
|
|
||||||
|
# Check for config values (formatted JSON)
|
||||||
|
assert "temperatures" in html
|
||||||
|
assert "input_voltage" in html
|
||||||
|
|
||||||
|
def test_render_with_charts(
|
||||||
|
self, renderer: HTMLRenderer, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test that render includes chart images."""
|
||||||
|
# Create sample base64 chart data
|
||||||
|
charts = {
|
||||||
|
"Test Chart": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||||
|
}
|
||||||
|
|
||||||
|
data = ReportData(
|
||||||
|
run=sample_run,
|
||||||
|
results=sample_results,
|
||||||
|
charts=charts,
|
||||||
|
config=ReportConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
html = renderer.render(data)
|
||||||
|
|
||||||
|
# Check for chart section and base64 image
|
||||||
|
assert "Charts" in html
|
||||||
|
assert "data:image/png;base64," in html
|
||||||
|
|
||||||
|
def test_render_empty_results(
|
||||||
|
self, renderer: HTMLRenderer, sample_run: TestRun
|
||||||
|
) -> None:
|
||||||
|
"""Test rendering with no results."""
|
||||||
|
data = ReportData(
|
||||||
|
run=sample_run,
|
||||||
|
results=[],
|
||||||
|
config=ReportConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
html = renderer.render(data)
|
||||||
|
|
||||||
|
# Should still produce valid HTML
|
||||||
|
assert "<!DOCTYPE html>" in html
|
||||||
|
assert "No results recorded" in html
|
||||||
|
|
||||||
|
def test_css_is_cached(self, renderer: HTMLRenderer) -> None:
|
||||||
|
"""Test that CSS content is cached after first load."""
|
||||||
|
# Access CSS twice
|
||||||
|
css1 = renderer._load_css()
|
||||||
|
css2 = renderer._load_css()
|
||||||
|
|
||||||
|
# Should be the same object (cached)
|
||||||
|
assert css1 is css2
|
||||||
|
assert len(css1) > 0
|
||||||
|
|
||||||
|
def test_render_formats_limits(
|
||||||
|
self, renderer: HTMLRenderer, sample_run: TestRun
|
||||||
|
) -> None:
|
||||||
|
"""Test that limits are properly formatted."""
|
||||||
|
results = [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="test_param",
|
||||||
|
value=50.0,
|
||||||
|
unit="units",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
lower_limit=10.0,
|
||||||
|
upper_limit=100.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
data = ReportData(run=sample_run, results=results, config=ReportConfig())
|
||||||
|
html = renderer.render(data)
|
||||||
|
|
||||||
|
# Check limits are formatted
|
||||||
|
assert "10.000000" in html
|
||||||
|
assert "100.000000" in html
|
||||||
208
tests/unit/reporting/test_models.py
Normal file
208
tests/unit/reporting/test_models.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Unit tests for reporting data models."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import TestResult, TestRun, TestStatus
|
||||||
|
from py_dvt_ate.reporting.models import ReportConfig, ReportData
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportConfig:
|
||||||
|
"""Tests for ReportConfig dataclass."""
|
||||||
|
|
||||||
|
def test_default_values(self) -> None:
|
||||||
|
"""Test default configuration values."""
|
||||||
|
config = ReportConfig()
|
||||||
|
|
||||||
|
assert config.company_name == "py_dvt_ate"
|
||||||
|
assert config.logo_path is None
|
||||||
|
assert config.include_charts is True
|
||||||
|
assert config.chart_dpi == 150
|
||||||
|
|
||||||
|
def test_custom_values(self) -> None:
|
||||||
|
"""Test configuration with custom values."""
|
||||||
|
config = ReportConfig(
|
||||||
|
company_name="Test Company",
|
||||||
|
logo_path=Path("/path/to/logo.png"),
|
||||||
|
include_charts=False,
|
||||||
|
chart_dpi=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.company_name == "Test Company"
|
||||||
|
assert config.logo_path == Path("/path/to/logo.png")
|
||||||
|
assert config.include_charts is False
|
||||||
|
assert config.chart_dpi == 300
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportData:
|
||||||
|
"""Tests for ReportData dataclass."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_run(self) -> TestRun:
|
||||||
|
"""Create a sample test run."""
|
||||||
|
return TestRun(
|
||||||
|
id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
test_name="tempco",
|
||||||
|
started_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||||
|
completed_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
status=TestStatus.PASSED,
|
||||||
|
config_json='{"temperatures": [-40, 25, 85]}',
|
||||||
|
operator="test_user",
|
||||||
|
description="Test description",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_results(self) -> list[TestResult]:
|
||||||
|
"""Create sample test results."""
|
||||||
|
return [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="tempco",
|
||||||
|
value=45.0,
|
||||||
|
unit="ppm/C",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
lower_limit=None,
|
||||||
|
upper_limit=100.0,
|
||||||
|
),
|
||||||
|
TestResult(
|
||||||
|
id="result-2",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="output_voltage_25c",
|
||||||
|
value=3.3001,
|
||||||
|
unit="V",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 33, 0),
|
||||||
|
lower_limit=3.2,
|
||||||
|
upper_limit=3.4,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_basic_report_data(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test creating basic report data."""
|
||||||
|
data = ReportData(run=sample_run, results=sample_results)
|
||||||
|
|
||||||
|
assert data.run == sample_run
|
||||||
|
assert data.results == sample_results
|
||||||
|
assert data.measurements is None
|
||||||
|
assert data.charts == {}
|
||||||
|
|
||||||
|
def test_passed_count(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test passed_count property."""
|
||||||
|
data = ReportData(run=sample_run, results=sample_results)
|
||||||
|
|
||||||
|
# Both results should pass (within limits)
|
||||||
|
assert data.passed_count == 2
|
||||||
|
|
||||||
|
def test_failed_count(self, sample_run: TestRun) -> None:
|
||||||
|
"""Test failed_count property with failed results."""
|
||||||
|
failed_results = [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="tempco",
|
||||||
|
value=150.0, # Exceeds upper limit
|
||||||
|
unit="ppm/C",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
lower_limit=None,
|
||||||
|
upper_limit=100.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
data = ReportData(run=sample_run, results=failed_results)
|
||||||
|
|
||||||
|
assert data.failed_count == 1
|
||||||
|
assert data.passed_count == 0
|
||||||
|
|
||||||
|
def test_overall_status_pass(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test overall_status when all tests pass."""
|
||||||
|
data = ReportData(run=sample_run, results=sample_results)
|
||||||
|
|
||||||
|
assert data.overall_status == "PASS"
|
||||||
|
|
||||||
|
def test_overall_status_fail(self, sample_run: TestRun) -> None:
|
||||||
|
"""Test overall_status when tests fail."""
|
||||||
|
failed_results = [
|
||||||
|
TestResult(
|
||||||
|
id="result-1",
|
||||||
|
test_run_id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
parameter="tempco",
|
||||||
|
value=150.0, # Exceeds upper limit
|
||||||
|
unit="ppm/C",
|
||||||
|
measured_at=datetime(2024, 1, 15, 10, 35, 0),
|
||||||
|
lower_limit=None,
|
||||||
|
upper_limit=100.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
data = ReportData(run=sample_run, results=failed_results)
|
||||||
|
|
||||||
|
assert data.overall_status == "FAIL"
|
||||||
|
|
||||||
|
def test_overall_status_error(self) -> None:
|
||||||
|
"""Test overall_status when run status is error."""
|
||||||
|
error_run = TestRun(
|
||||||
|
id="12345678-1234-1234-1234-123456789abc",
|
||||||
|
test_name="tempco",
|
||||||
|
started_at=datetime(2024, 1, 15, 10, 30, 0),
|
||||||
|
status=TestStatus.ERROR,
|
||||||
|
config_json="{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = ReportData(run=error_run, results=[])
|
||||||
|
|
||||||
|
assert data.overall_status == "ERROR"
|
||||||
|
|
||||||
|
def test_with_measurements(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test report data with measurements DataFrame."""
|
||||||
|
measurements = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"timestamp": [0.0, 1.0, 2.0],
|
||||||
|
"parameter": ["output_voltage", "output_voltage", "output_voltage"],
|
||||||
|
"value": [3.30, 3.31, 3.30],
|
||||||
|
"unit": ["V", "V", "V"],
|
||||||
|
"temperature": [25.0, 25.0, 25.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = ReportData(
|
||||||
|
run=sample_run, results=sample_results, measurements=measurements
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.measurements is not None
|
||||||
|
assert len(data.measurements) == 3
|
||||||
|
|
||||||
|
def test_with_charts(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test report data with chart images."""
|
||||||
|
charts = {
|
||||||
|
"Voltage vs Temperature": "base64_encoded_image_data",
|
||||||
|
"Results Summary": "another_base64_image",
|
||||||
|
}
|
||||||
|
|
||||||
|
data = ReportData(run=sample_run, results=sample_results, charts=charts)
|
||||||
|
|
||||||
|
assert len(data.charts) == 2
|
||||||
|
assert "Voltage vs Temperature" in data.charts
|
||||||
|
|
||||||
|
def test_with_custom_config(
|
||||||
|
self, sample_run: TestRun, sample_results: list[TestResult]
|
||||||
|
) -> None:
|
||||||
|
"""Test report data with custom configuration."""
|
||||||
|
config = ReportConfig(company_name="Test Company", include_charts=False)
|
||||||
|
|
||||||
|
data = ReportData(run=sample_run, results=sample_results, config=config)
|
||||||
|
|
||||||
|
assert data.config.company_name == "Test Company"
|
||||||
|
assert data.config.include_charts is False
|
||||||
@@ -8,11 +8,11 @@ import pytest
|
|||||||
|
|
||||||
from py_dvt_ate.instruments import (
|
from py_dvt_ate.instruments import (
|
||||||
IMultimeter,
|
IMultimeter,
|
||||||
IPowerSupply,
|
|
||||||
IThermalChamber,
|
|
||||||
InstrumentConfig,
|
InstrumentConfig,
|
||||||
InstrumentFactory,
|
InstrumentFactory,
|
||||||
InstrumentSet,
|
InstrumentSet,
|
||||||
|
IPowerSupply,
|
||||||
|
IThermalChamber,
|
||||||
)
|
)
|
||||||
from py_dvt_ate.instruments.drivers import (
|
from py_dvt_ate.instruments.drivers import (
|
||||||
MultimeterDriver,
|
MultimeterDriver,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from py_dvt_ate.data.repository import SQLiteRepository
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_db():
|
def temp_db():
|
||||||
"""Create a temporary database for testing."""
|
"""Create a temporary database for testing."""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
# Use ignore_cleanup_errors=True for Windows file locking issues
|
||||||
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test.db"
|
db_path = Path(tmpdir) / "test.db"
|
||||||
yield db_path
|
yield db_path
|
||||||
|
|
||||||
@@ -22,7 +23,14 @@ def temp_db():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repository(temp_db):
|
def repository(temp_db):
|
||||||
"""Create a repository instance for testing."""
|
"""Create a repository instance for testing."""
|
||||||
return SQLiteRepository(temp_db)
|
import gc
|
||||||
|
repo = SQLiteRepository(temp_db)
|
||||||
|
yield repo
|
||||||
|
# Ensure all connections and file handles are closed before cleanup
|
||||||
|
# This is critical on Windows to prevent PermissionError
|
||||||
|
repo.close()
|
||||||
|
del repo
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
def test_create_run(repository):
|
def test_create_run(repository):
|
||||||
@@ -235,6 +243,7 @@ def test_multiple_results(repository):
|
|||||||
|
|
||||||
def test_custom_measurements_dir(temp_db):
|
def test_custom_measurements_dir(temp_db):
|
||||||
"""Test using a custom measurements directory."""
|
"""Test using a custom measurements directory."""
|
||||||
|
import gc
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
measurements_dir = Path(tmpdir) / "custom_measurements"
|
measurements_dir = Path(tmpdir) / "custom_measurements"
|
||||||
repo = SQLiteRepository(temp_db, measurements_dir=measurements_dir)
|
repo = SQLiteRepository(temp_db, measurements_dir=measurements_dir)
|
||||||
@@ -254,6 +263,10 @@ def test_custom_measurements_dir(temp_db):
|
|||||||
expected_path = measurements_dir / f"run_{run_id}" / "measurements.parquet"
|
expected_path = measurements_dir / f"run_{run_id}" / "measurements.parquet"
|
||||||
assert expected_path.exists()
|
assert expected_path.exists()
|
||||||
|
|
||||||
|
# Clean up repository before temp directory cleanup
|
||||||
|
del repo
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
def test_parquet_schema(repository):
|
def test_parquet_schema(repository):
|
||||||
"""Test that Parquet file has correct schema."""
|
"""Test that Parquet file has correct schema."""
|
||||||
|
|||||||
Reference in New Issue
Block a user