Compare commits
112 Commits
v0.1.0-alp
...
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 | |||
| 2e62a10550 | |||
| d07e6e3f1a | |||
| 96eb83cec4 | |||
| 027fd71505 | |||
| 3310e86fae | |||
| e42de212f2 | |||
| ee8d148eb7 | |||
| e379b7e432 | |||
| eaa1843ee1 | |||
| 7429f6433c | |||
| 7cfd36f02b | |||
| f5600efd76 | |||
| 0615eb7e07 | |||
| b981182b71 | |||
| 8c0d68e722 | |||
| 4e14222522 | |||
| afa52e7ee2 | |||
| a951413a62 | |||
| 0b58f7e863 | |||
| a8bd132269 | |||
| 0a8d7e5c69 | |||
| ece1803c10 | |||
| 76d81b21e6 | |||
| 4db50421b3 | |||
| 10e1da198e | |||
| 8fe97047d1 | |||
| 1f00210b63 | |||
| 95961cd26f | |||
| fe208b0c04 | |||
| d38c40d52d | |||
| 936ed5a279 | |||
| 284793df69 | |||
| e38f514153 | |||
| cfe8dab7a8 | |||
| 9e9c0ae0e5 | |||
| a742d57a6f | |||
| 2d358062f4 | |||
| 1a489b9106 | |||
| f9e59da32b | |||
| a4c01c856d | |||
| 144e80f87a | |||
| e811b21082 | |||
| 9a88a35cc5 | |||
| b31324a42a | |||
| 008134844d | |||
| ae85948539 | |||
| bccb8cc420 | |||
| 510e1ba683 | |||
| 5e69085875 | |||
| 5053399851 | |||
| d54ada18b2 | |||
| 252c329562 | |||
| 6e7da7f382 | |||
| 75e0a1cc25 |
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:
|
||||
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
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
pip install -e ".[dev,reports]"
|
||||
|
||||
- name: Run pytest
|
||||
run: pytest --cov=src/py_dvt_ate --cov-report=xml
|
||||
@@ -72,7 +77,7 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
needs: [lint, typecheck, test]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -91,7 +96,22 @@ jobs:
|
||||
run: python -m build
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
run: |
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
VERSION=${TAG_NAME#v}
|
||||
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
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
CLAUDE.md
|
||||
|
||||
130
CHANGELOG.md
130
CHANGELOG.md
@@ -7,6 +7,128 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- Test Executive Framework (Sprint 14)
|
||||
- TestContext dataclass providing runtime context for tests
|
||||
- ITest abstract base class defining test interface
|
||||
- TestLogger for recording measurements, results, and events
|
||||
- LimitChecker for evaluating pass/fail against specification limits
|
||||
- TestRunner for orchestrating test execution
|
||||
- SQLite-based TestRepository for persisting test data
|
||||
- Parquet measurement storage for efficient time-series data
|
||||
- DVT Test Implementation (Sprint 15)
|
||||
- BaseDVTTest providing common test utilities
|
||||
- TempCo characterisation test (temperature coefficient measurement)
|
||||
- Temperature sweep with automatic thermal settling
|
||||
- Linear regression TempCo calculation (ppm/°C)
|
||||
- Comprehensive integration tests for end-to-end validation
|
||||
|
||||
### Technical
|
||||
- Test framework supports data logging, limit evaluation, and result persistence
|
||||
- TempCo test demonstrates full end-to-end workflow: configure instruments → sweep temperature → measure → calculate → evaluate
|
||||
- All framework and test components fully type-checked and linted
|
||||
|
||||
## [0.1.0-beta.1] - 2025-12-02
|
||||
|
||||
### Added
|
||||
- Hardware Abstraction Layer (HAL) with instrument interface protocols
|
||||
- IThermalChamber protocol with temperature control methods
|
||||
- IPowerSupply protocol with voltage/current control and measurement
|
||||
- IMultimeter protocol with DC voltage, current, and resistance measurement
|
||||
- Instrument drivers implementing HAL interfaces
|
||||
- ThermalChamberDriver implements IThermalChamber
|
||||
- PowerSupplyDriver implements IPowerSupply
|
||||
- MultimeterDriver implements IMultimeter
|
||||
- Instrument factory pattern for backend abstraction
|
||||
- InstrumentSet dataclass containing chamber, PSU, and DMM
|
||||
- InstrumentConfig for specifying backend (simulator/pyvisa) and connection details
|
||||
- InstrumentFactory.create() for creating instrument sets from configuration
|
||||
- Transport layer abstraction
|
||||
- Transport ABC defining connect/disconnect/read/write/query interface
|
||||
- TCPTransport implementation for TCP/IP connections
|
||||
- Comprehensive test suite for HAL (16 tests)
|
||||
- Interface implementation verification
|
||||
- Factory pattern testing with mocked backends
|
||||
- Configuration validation
|
||||
|
||||
### Changed
|
||||
- Drivers now explicitly inherit from interface ABCs for maximum type safety
|
||||
- Moved InstrumentServer to instruments/transport for better architecture
|
||||
|
||||
### Technical
|
||||
- ABC-based interfaces ensure compile-time interface compliance
|
||||
- Factory pattern enables seamless switching between simulated and real hardware
|
||||
- All HAL components fully type-checked with mypy strict mode
|
||||
|
||||
## [0.1.0-alpha.3] - 2025-12-02
|
||||
|
||||
### Added
|
||||
- Async TCP server for exposing virtual instruments over network
|
||||
- InstrumentServer class with multi-port, multi-client support
|
||||
- Line-based SCPI protocol (newline-terminated commands/responses)
|
||||
- SimulationServer wiring physics engine to all virtual instruments
|
||||
- CLI `serve` command to start simulation server with configurable ports
|
||||
- Integration tests for TCP server and instrument connectivity
|
||||
|
||||
### Infrastructure
|
||||
- SCPI foundation (Sprint 5): command parser with IEEE 488.2 support
|
||||
- Virtual instrument base class with command dispatch
|
||||
- Thermal chamber simulator (TEMP:SETPOINT, TEMP:ACTUAL?, TEMP:STAB?)
|
||||
- Power supply simulator (VOLT, CURR, OUTP, MEAS commands)
|
||||
- Multimeter simulator (MEAS:VOLT:DC?, MEAS:CURR:DC?, CONF, READ?)
|
||||
|
||||
## [0.1.0-alpha.2] - 2025-12-02
|
||||
|
||||
### Added
|
||||
@@ -56,10 +178,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
| Version | Date | Milestone |
|
||||
|---------|------|-----------|
|
||||
| 0.1.0 | TBD | MVP Complete |
|
||||
| 0.1.0-beta.2 | TBD | First DVT test runs |
|
||||
| 0.1.0-beta.1 | TBD | HAL complete |
|
||||
| 0.1.0-alpha.3 | TBD | Network ready |
|
||||
| 0.1.0 | 2025-12-04 | MVP Complete |
|
||||
| 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-alpha.3 | 2025-12-02 | Network ready |
|
||||
| 0.1.0-alpha.2 | 2025-12-02 | Visual demo |
|
||||
| 0.1.0-alpha.1 | 2025-12-02 | Physics engine |
|
||||
| 0.0.1 | 2025-12-01 | Project scaffolding |
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
- **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 |
|
||||
| [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
|
||||
|
||||
**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
|
||||
|
||||
@@ -50,4 +117,4 @@ Kai Chappell
|
||||
|
||||
## Licence
|
||||
|
||||
TBD
|
||||
Proprietary - All rights reserved. See [LICENSE](LICENSE) for details.
|
||||
|
||||
172
config/default.yaml
Normal file
172
config/default.yaml
Normal file
@@ -0,0 +1,172 @@
|
||||
# py_dvt_ate Default Configuration
|
||||
# This file contains default settings for the DVT simulation platform.
|
||||
# Copy this file and modify values as needed for your environment.
|
||||
|
||||
# =============================================================================
|
||||
# Instrument Configuration
|
||||
# =============================================================================
|
||||
instruments:
|
||||
# Backend selection: "simulator" or "pyvisa"
|
||||
# - simulator: Use virtual instruments with physics simulation (for development)
|
||||
# - pyvisa: Connect to real instruments via PyVISA (for production testing)
|
||||
backend: simulator
|
||||
|
||||
# Simulator backend configuration
|
||||
# Used when backend=simulator. Virtual instruments are exposed as TCP servers.
|
||||
simulator:
|
||||
host: localhost
|
||||
thermal_chamber_port: 5001
|
||||
power_supply_port: 5002
|
||||
multimeter_port: 5003
|
||||
|
||||
# PyVISA backend configuration
|
||||
# Used when backend=pyvisa. Provide VISA resource strings for real instruments.
|
||||
# Example: "TCPIP::192.168.1.10::5001::SOCKET"
|
||||
pyvisa:
|
||||
thermal_chamber: null
|
||||
power_supply: null
|
||||
multimeter: null
|
||||
|
||||
# =============================================================================
|
||||
# Physics Simulation Parameters
|
||||
# =============================================================================
|
||||
physics:
|
||||
# Physics engine update rate (Hz)
|
||||
# Higher rates provide better accuracy but use more CPU.
|
||||
update_rate_hz: 100.0
|
||||
|
||||
# Thermal model parameters
|
||||
thermal:
|
||||
# Chamber thermal time constant (seconds)
|
||||
# Time for chamber temperature to reach 63% of final value
|
||||
chamber_time_constant_s: 30.0
|
||||
|
||||
# DUT case thermal time constant (seconds)
|
||||
# Time for case temperature to reach 63% of final value
|
||||
case_time_constant_s: 5.0
|
||||
|
||||
# Junction-to-case thermal resistance (°C/W)
|
||||
# How much the junction heats above case per watt dissipated
|
||||
theta_jc: 15.0
|
||||
|
||||
# Case-to-ambient thermal resistance (°C/W)
|
||||
# How much the case heats above ambient per watt dissipated
|
||||
theta_ca: 5.0
|
||||
|
||||
# Thermal chamber behaviour
|
||||
chamber:
|
||||
# Maximum temperature ramp rate (°C/min)
|
||||
# Real chambers have limited heating/cooling rates
|
||||
ramp_rate_c_per_min: 10.0
|
||||
|
||||
# Temperature stability window (°C)
|
||||
# Chamber is considered stable when within ±this value of setpoint
|
||||
stability_window_c: 0.5
|
||||
|
||||
# Stability duration requirement (seconds)
|
||||
# Chamber must remain in stability window for this duration
|
||||
stability_time_s: 30.0
|
||||
|
||||
# =============================================================================
|
||||
# DUT (Device Under Test) Configuration
|
||||
# =============================================================================
|
||||
dut:
|
||||
# DUT model type
|
||||
# Currently supported: "ldo"
|
||||
model: ldo
|
||||
|
||||
# DUT model parameters
|
||||
parameters:
|
||||
# Nominal output voltage at 25°C (V)
|
||||
nominal_output_voltage: 3.3
|
||||
|
||||
# Temperature coefficient (ppm/°C)
|
||||
# Voltage change per degree: ΔV = V₀ × tempco × ΔT / 1e6
|
||||
tempco_ppm_per_c: 50.0
|
||||
|
||||
# Quiescent current at 25°C (µA)
|
||||
quiescent_current_ua: 50.0
|
||||
|
||||
# Quiescent current temperature coefficient (per °C)
|
||||
# Iq change per degree: ΔIq = Iq₀ × tempco × ΔT
|
||||
quiescent_current_tempco: 0.003
|
||||
|
||||
# Dropout voltage (V)
|
||||
# Minimum Vin-Vout differential for regulation
|
||||
dropout_voltage: 0.3
|
||||
|
||||
# =============================================================================
|
||||
# Data Storage Configuration
|
||||
# =============================================================================
|
||||
data:
|
||||
# SQLite database path for test runs and results
|
||||
database_path: ./data/py_dvt_ate.db
|
||||
|
||||
# Directory for measurement data files (Parquet format)
|
||||
measurements_dir: ./data/measurements
|
||||
|
||||
# Directory for generated reports (PDF, HTML)
|
||||
reports_dir: ./data/reports
|
||||
|
||||
# =============================================================================
|
||||
# Logging Configuration
|
||||
# =============================================================================
|
||||
logging:
|
||||
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
level: INFO
|
||||
|
||||
# Log file path
|
||||
# Use null to disable file logging
|
||||
file: ./data/logs/py_dvt_ate.log
|
||||
|
||||
# Log message format
|
||||
# Uses Python logging format strings
|
||||
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
# =============================================================================
|
||||
# Dashboard Configuration (Streamlit)
|
||||
# =============================================================================
|
||||
dashboard:
|
||||
# Enable/disable the Streamlit dashboard
|
||||
enabled: true
|
||||
|
||||
# Dashboard server port
|
||||
port: 8501
|
||||
|
||||
# =============================================================================
|
||||
# API Configuration (Phase 2)
|
||||
# =============================================================================
|
||||
api:
|
||||
# Enable/disable the REST API server
|
||||
# Currently not implemented (Phase 2 feature)
|
||||
enabled: false
|
||||
|
||||
# API server host
|
||||
# Use "0.0.0.0" to listen on all interfaces
|
||||
host: "0.0.0.0"
|
||||
|
||||
# API server port
|
||||
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
|
||||
```
|
||||
@@ -450,109 +450,123 @@ High-level call → Driver → SCPI command → Transport → Instrument
|
||||
```python
|
||||
# py_dvt_ate/instruments/interfaces.py
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IThermalChamber(Protocol):
|
||||
class IThermalChamber(ABC):
|
||||
"""Hardware abstraction for thermal chambers."""
|
||||
|
||||
@abstractmethod
|
||||
def set_temperature(self, setpoint: float) -> None:
|
||||
"""Set target temperature in degrees Celsius."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_temperature(self) -> float:
|
||||
"""Get current actual temperature in degrees Celsius."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_setpoint(self) -> float:
|
||||
"""Get current temperature setpoint."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_stable(self) -> bool:
|
||||
"""Check if temperature has stabilised at setpoint."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def wait_until_stable(
|
||||
self,
|
||||
timeout: float = 300.0,
|
||||
poll_interval: float = 1.0
|
||||
) -> bool:
|
||||
"""
|
||||
Block until temperature stabilises or timeout.
|
||||
|
||||
Returns:
|
||||
True if stable, False if timeout
|
||||
"""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_ramp_rate(self, rate: float) -> None:
|
||||
"""Set temperature ramp rate in degrees C per minute."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IPowerSupply(Protocol):
|
||||
class IPowerSupply(ABC):
|
||||
"""Hardware abstraction for programmable power supplies."""
|
||||
|
||||
@abstractmethod
|
||||
def set_voltage(self, channel: int, voltage: float) -> None:
|
||||
"""Set output voltage for specified channel."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_voltage(self, channel: int) -> float:
|
||||
"""Get voltage setpoint for specified channel."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_current_limit(self, channel: int, current: float) -> None:
|
||||
"""Set current limit for specified channel."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_limit(self, channel: int) -> float:
|
||||
"""Get current limit for specified channel."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_voltage(self, channel: int) -> float:
|
||||
"""Measure actual output voltage."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_current(self, channel: int) -> float:
|
||||
"""Measure actual output current."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def enable_output(self, channel: int, enable: bool) -> None:
|
||||
"""Enable or disable channel output."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_output_enabled(self, channel: int) -> bool:
|
||||
"""Check if channel output is enabled."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IMultimeter(Protocol):
|
||||
class IMultimeter(ABC):
|
||||
"""Hardware abstraction for digital multimeters."""
|
||||
|
||||
@abstractmethod
|
||||
def measure_dc_voltage(self, range: str = "AUTO") -> float:
|
||||
"""Measure DC voltage. Range: AUTO, 0.1, 1, 10, 100, 1000."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_dc_current(self, range: str = "AUTO") -> float:
|
||||
"""Measure DC current."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_resistance(self, range: str = "AUTO") -> float:
|
||||
"""Measure resistance."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_integration_time(self, nplc: float) -> None:
|
||||
"""Set integration time in power line cycles (0.1 to 100)."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ITestLogger(Protocol):
|
||||
class ITestLogger(ABC):
|
||||
"""Abstraction for test data logging."""
|
||||
|
||||
@abstractmethod
|
||||
def log_measurement(
|
||||
self,
|
||||
parameter: str,
|
||||
@@ -560,9 +574,10 @@ class ITestLogger(Protocol):
|
||||
unit: str,
|
||||
conditions: dict[str, float] | None = None
|
||||
) -> None:
|
||||
"""Log a single measurement."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def log_result(
|
||||
self,
|
||||
parameter: str,
|
||||
@@ -571,12 +586,13 @@ class ITestLogger(Protocol):
|
||||
lower_limit: float | None = None,
|
||||
upper_limit: float | None = None
|
||||
) -> None:
|
||||
"""Log a test result with optional limits."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def log_event(self, message: str, level: str = "INFO") -> None:
|
||||
"""Log a test event or message."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 4.2 Transport Interface
|
||||
@@ -584,36 +600,42 @@ class ITestLogger(Protocol):
|
||||
```python
|
||||
# py_dvt_ate/instruments/transport/base.py
|
||||
|
||||
from typing import Protocol
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
class Transport(ABC):
|
||||
"""Abstract transport interface for instrument communication."""
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> None:
|
||||
"""Establish connection to instrument."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Close connection to instrument."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, command: str) -> None:
|
||||
"""Send command to instrument."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def read(self, timeout: float | None = None) -> str:
|
||||
"""Read response from instrument."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def query(self, command: str, timeout: float | None = None) -> str:
|
||||
"""Send command and read response."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connection is active."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 4.3 Test Interface
|
||||
@@ -624,7 +646,7 @@ class Transport(Protocol):
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
from abc import ABC, abstractmethod
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@@ -675,22 +697,25 @@ class TestContext:
|
||||
config: dict
|
||||
|
||||
|
||||
class ITest(Protocol):
|
||||
class ITest(ABC):
|
||||
"""Interface for test implementations."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Test name identifier."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Human-readable test description."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, context: TestContext) -> TestStatus:
|
||||
"""Execute the test, return status."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
```
|
||||
|
||||
### 4.4 Factory Interface
|
||||
@@ -1173,30 +1198,34 @@ Schema:
|
||||
```python
|
||||
# py_dvt_ate/data/repository.py (interface)
|
||||
|
||||
from typing import Protocol
|
||||
from abc import ABC, abstractmethod
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class ITestRepository(Protocol):
|
||||
class ITestRepository(ABC):
|
||||
"""Repository interface for test data."""
|
||||
|
||||
@abstractmethod
|
||||
def create_run(
|
||||
self,
|
||||
test_name: str,
|
||||
config: dict,
|
||||
operator: str | None = None
|
||||
) -> UUID:
|
||||
"""Create a new test run, return its ID."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_run_status(self, run_id: UUID, status: str) -> None:
|
||||
"""Update test run status."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def complete_run(self, run_id: UUID, status: str) -> None:
|
||||
"""Mark test run as complete with final status."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_result(
|
||||
self,
|
||||
run_id: UUID,
|
||||
@@ -1206,28 +1235,32 @@ class ITestRepository(Protocol):
|
||||
lower_limit: float | None = None,
|
||||
upper_limit: float | None = None
|
||||
) -> None:
|
||||
"""Save a test result."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_measurements(
|
||||
self,
|
||||
run_id: UUID,
|
||||
measurements: list["Measurement"]
|
||||
) -> None:
|
||||
"""Save batch of measurements to Parquet."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_run(self, run_id: UUID) -> "TestRun":
|
||||
"""Get test run by ID."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_results(self, run_id: UUID) -> list["TestResult"]:
|
||||
"""Get all results for a test run."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_measurements_dataframe(self, run_id: UUID):
|
||||
"""Get measurements as pandas DataFrame."""
|
||||
...
|
||||
"""[docstring]"""
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
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 = [
|
||||
"jinja2>=3.1",
|
||||
"weasyprint>=60.0",
|
||||
"matplotlib>=3.8",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
@@ -37,6 +38,8 @@ dev = [
|
||||
"pytest-asyncio>=0.21",
|
||||
"ruff>=0.1",
|
||||
"mypy>=1.0",
|
||||
"types-PyYAML>=6.0",
|
||||
"pandas-stubs>=2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -81,10 +84,15 @@ disallow_incomplete_defs = true
|
||||
module = [
|
||||
"streamlit.*",
|
||||
"plotly.*",
|
||||
"weasyprint.*",
|
||||
"matplotlib.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.pytest-asyncio]
|
||||
mode = "auto"
|
||||
default_fixture_loop_scope = "function"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
|
||||
|
||||
__version__ = "0.1.0-alpha.2"
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Command-line interface for py_dvt_ate."""
|
||||
|
||||
from typing import Annotated, Optional
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
@@ -23,7 +23,7 @@ def version_callback(value: bool) -> None:
|
||||
@app.callback()
|
||||
def main(
|
||||
version: Annotated[
|
||||
Optional[bool],
|
||||
bool | None,
|
||||
typer.Option(
|
||||
"--version",
|
||||
"-v",
|
||||
@@ -36,5 +36,332 @@ def main(
|
||||
"""py-dvt-ate: Coupled Physics DVT Simulation Platform."""
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option("--host", "-h", help="Host address to bind to."),
|
||||
] = "127.0.0.1",
|
||||
chamber_port: Annotated[
|
||||
int,
|
||||
typer.Option("--chamber-port", help="Port for thermal chamber instrument."),
|
||||
] = 5000,
|
||||
psu_port: Annotated[
|
||||
int,
|
||||
typer.Option("--psu-port", help="Port for power supply instrument."),
|
||||
] = 5001,
|
||||
dmm_port: Annotated[
|
||||
int,
|
||||
typer.Option("--dmm-port", help="Port for multimeter instrument."),
|
||||
] = 5002,
|
||||
physics_rate: Annotated[
|
||||
float,
|
||||
typer.Option("--physics-rate", help="Physics engine update rate in Hz."),
|
||||
] = 100.0,
|
||||
) -> None:
|
||||
"""Start the simulation server with virtual instruments.
|
||||
|
||||
Runs a TCP server hosting virtual SCPI instruments connected to a
|
||||
shared physics engine. Each instrument listens on its own port.
|
||||
"""
|
||||
from py_dvt_ate.simulation.server import main as run_server
|
||||
|
||||
typer.echo(f"Starting simulation server on {host}...")
|
||||
typer.echo(f" Thermal chamber: port {chamber_port}")
|
||||
typer.echo(f" Power supply: port {psu_port}")
|
||||
typer.echo(f" Multimeter: port {dmm_port}")
|
||||
typer.echo(f" Physics rate: {physics_rate} Hz")
|
||||
typer.echo("")
|
||||
typer.echo("Press Ctrl+C to stop.")
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
chamber_port=chamber_port,
|
||||
psu_port=psu_port,
|
||||
dmm_port=dmm_port,
|
||||
physics_rate=physics_rate,
|
||||
)
|
||||
|
||||
|
||||
@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__":
|
||||
app()
|
||||
|
||||
210
src/py_dvt_ate/app/config.py
Normal file
210
src/py_dvt_ate/app/config.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Configuration models for py_dvt_ate.
|
||||
|
||||
This module defines Pydantic models for all configuration sections.
|
||||
Configuration can be loaded from YAML files and validated at runtime.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SimulatorConfig(BaseModel):
|
||||
"""Configuration for simulator instrument backend."""
|
||||
|
||||
host: str = "localhost"
|
||||
thermal_chamber_port: int = 5001
|
||||
power_supply_port: int = 5002
|
||||
multimeter_port: int = 5003
|
||||
|
||||
|
||||
class PyVISAConfig(BaseModel):
|
||||
"""Configuration for PyVISA instrument backend."""
|
||||
|
||||
thermal_chamber: str | None = None
|
||||
power_supply: str | None = None
|
||||
multimeter: str | None = None
|
||||
|
||||
|
||||
class InstrumentsConfig(BaseModel):
|
||||
"""Instrument backend configuration."""
|
||||
|
||||
backend: Literal["simulator", "pyvisa"] = "simulator"
|
||||
simulator: SimulatorConfig = Field(default_factory=SimulatorConfig)
|
||||
pyvisa: PyVISAConfig = Field(default_factory=PyVISAConfig)
|
||||
|
||||
|
||||
class ThermalConfig(BaseModel):
|
||||
"""Thermal physics parameters."""
|
||||
|
||||
chamber_time_constant_s: float = 30.0
|
||||
case_time_constant_s: float = 5.0
|
||||
theta_jc: float = 15.0 # °C/W (junction to case)
|
||||
theta_ca: float = 5.0 # °C/W (case to ambient)
|
||||
|
||||
|
||||
class ChamberConfig(BaseModel):
|
||||
"""Thermal chamber behaviour parameters."""
|
||||
|
||||
ramp_rate_c_per_min: float = 10.0
|
||||
stability_window_c: float = 0.5
|
||||
stability_time_s: float = 30.0
|
||||
|
||||
|
||||
class PhysicsConfig(BaseModel):
|
||||
"""Physics simulation parameters."""
|
||||
|
||||
update_rate_hz: float = 100.0
|
||||
thermal: ThermalConfig = Field(default_factory=ThermalConfig)
|
||||
chamber: ChamberConfig = Field(default_factory=ChamberConfig)
|
||||
|
||||
|
||||
class DUTParameters(BaseModel):
|
||||
"""DUT model parameters."""
|
||||
|
||||
nominal_output_voltage: float = 3.3
|
||||
tempco_ppm_per_c: float = 50.0
|
||||
quiescent_current_ua: float = 50.0
|
||||
quiescent_current_tempco: float = 0.003
|
||||
dropout_voltage: float = 0.3
|
||||
|
||||
|
||||
class DUTConfig(BaseModel):
|
||||
"""DUT model configuration."""
|
||||
|
||||
model: str = "ldo"
|
||||
parameters: DUTParameters = Field(default_factory=DUTParameters)
|
||||
|
||||
|
||||
class DataConfig(BaseModel):
|
||||
"""Data storage paths."""
|
||||
|
||||
database_path: str = "./data/py_dvt_ate.db"
|
||||
measurements_dir: str = "./data/measurements"
|
||||
reports_dir: str = "./data/reports"
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
"""Logging configuration."""
|
||||
|
||||
level: str = "INFO"
|
||||
file: str = "./data/logs/py_dvt_ate.log"
|
||||
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
|
||||
class DashboardConfig(BaseModel):
|
||||
"""Dashboard (Streamlit) configuration."""
|
||||
|
||||
enabled: bool = True
|
||||
port: int = 8501
|
||||
|
||||
|
||||
class APIConfig(BaseModel):
|
||||
"""API server configuration (Phase 2)."""
|
||||
|
||||
enabled: bool = False
|
||||
host: str = "0.0.0.0"
|
||||
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):
|
||||
"""Root configuration model."""
|
||||
|
||||
instruments: InstrumentsConfig = Field(default_factory=InstrumentsConfig)
|
||||
physics: PhysicsConfig = Field(default_factory=PhysicsConfig)
|
||||
dut: DUTConfig = Field(default_factory=DUTConfig)
|
||||
data: DataConfig = Field(default_factory=DataConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
|
||||
api: APIConfig = Field(default_factory=APIConfig)
|
||||
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
|
||||
|
||||
|
||||
def _apply_env_overrides(config_dict: dict[str, Any]) -> None:
|
||||
"""Apply environment variable overrides to config dictionary.
|
||||
|
||||
Environment variables follow the pattern: PYDVTATE__{SECTION}__{KEY}
|
||||
For nested keys, use double underscores: PYDVTATE__{SECTION}__{SUBSECTION}__{KEY}
|
||||
|
||||
Examples:
|
||||
PYDVTATE__INSTRUMENTS__BACKEND=pyvisa
|
||||
PYDVTATE__PHYSICS__UPDATE_RATE_HZ=50.0
|
||||
PYDVTATE__SIMULATOR__HOST=192.168.1.100
|
||||
"""
|
||||
prefix = "PYDVTATE__"
|
||||
|
||||
for env_key, env_value in os.environ.items():
|
||||
if not env_key.startswith(prefix):
|
||||
continue
|
||||
|
||||
# Remove prefix and split into parts
|
||||
key_parts = env_key[len(prefix) :].lower().split("__")
|
||||
|
||||
# Navigate/create nested structure
|
||||
current = config_dict
|
||||
for part in key_parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Set the final value
|
||||
final_key = key_parts[-1]
|
||||
# Try to parse as YAML to handle types (int, float, bool, etc.)
|
||||
try:
|
||||
current[final_key] = yaml.safe_load(env_value)
|
||||
except yaml.YAMLError:
|
||||
# If parsing fails, use as string
|
||||
current[final_key] = env_value
|
||||
|
||||
|
||||
def load_config(config_path: str | Path | None = None) -> AppConfig:
|
||||
"""Load configuration from YAML file with environment variable overrides.
|
||||
|
||||
Args:
|
||||
config_path: Path to YAML configuration file. If None, uses defaults only.
|
||||
|
||||
Returns:
|
||||
Validated AppConfig instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config_path is provided but does not exist.
|
||||
yaml.YAMLError: If YAML file is malformed.
|
||||
pydantic.ValidationError: If configuration is invalid.
|
||||
|
||||
Environment Variables:
|
||||
Configuration can be overridden using environment variables with the
|
||||
pattern PYDVTATE__{SECTION}__{KEY}. For example:
|
||||
PYDVTATE__INSTRUMENTS__BACKEND=pyvisa
|
||||
PYDVTATE__PHYSICS__UPDATE_RATE_HZ=50.0
|
||||
"""
|
||||
# Start with empty dict (will use Pydantic defaults)
|
||||
config_dict: dict[str, Any] = {}
|
||||
|
||||
# Load from YAML file if provided
|
||||
if config_path is not None:
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||
|
||||
with path.open("r") as f:
|
||||
loaded = yaml.safe_load(f)
|
||||
if loaded is not None:
|
||||
config_dict = loaded
|
||||
|
||||
# Apply environment variable overrides
|
||||
_apply_env_overrides(config_dict)
|
||||
|
||||
# Validate and return
|
||||
return AppConfig(**config_dict)
|
||||
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
|
||||
14
src/py_dvt_ate/data/__init__.py
Normal file
14
src/py_dvt_ate/data/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Data persistence layer.
|
||||
|
||||
Provides storage for test runs, results, and measurements using
|
||||
SQLite for metadata and Parquet for time-series data.
|
||||
"""
|
||||
|
||||
from py_dvt_ate.data.models import Measurement, TestResult, TestRun, TestStatus
|
||||
|
||||
__all__ = [
|
||||
"Measurement",
|
||||
"TestResult",
|
||||
"TestRun",
|
||||
"TestStatus",
|
||||
]
|
||||
83
src/py_dvt_ate/data/models.py
Normal file
83
src/py_dvt_ate/data/models.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Data models for test persistence.
|
||||
|
||||
This module defines dataclasses representing test runs, results, and measurements.
|
||||
These models map to SQLite tables (for metadata) and Parquet files (for time-series).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TestStatus(Enum):
|
||||
"""Test run status."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
PASSED = "passed"
|
||||
FAILED = "failed"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestRun:
|
||||
"""Test run metadata.
|
||||
|
||||
Maps to the test_runs SQLite table.
|
||||
"""
|
||||
|
||||
id: str # UUID
|
||||
test_name: str
|
||||
started_at: datetime
|
||||
status: TestStatus
|
||||
config_json: str # JSON string of test configuration
|
||||
description: str | None = None
|
||||
completed_at: datetime | None = None
|
||||
operator: str | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
"""Immutable test result with limits.
|
||||
|
||||
Maps to the test_results SQLite table.
|
||||
Represents a single scalar measurement with pass/fail limits.
|
||||
"""
|
||||
|
||||
id: str # UUID
|
||||
test_run_id: str # Foreign key to test_runs.id
|
||||
parameter: str
|
||||
value: float
|
||||
unit: str
|
||||
measured_at: datetime
|
||||
lower_limit: float | None = None
|
||||
upper_limit: float | None = None
|
||||
|
||||
@property
|
||||
def passed(self) -> bool | None:
|
||||
"""Evaluate pass/fail. None if no limits defined."""
|
||||
if self.lower_limit is None and self.upper_limit is None:
|
||||
return None
|
||||
lower_ok = self.lower_limit is None or self.value >= self.lower_limit
|
||||
upper_ok = self.upper_limit is None or self.value <= self.upper_limit
|
||||
return lower_ok and upper_ok
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Measurement:
|
||||
"""Immutable measurement record for time-series data.
|
||||
|
||||
Maps to Parquet files for efficient storage and analysis.
|
||||
Includes measurement conditions (temperature, voltage, current) at time of measurement.
|
||||
"""
|
||||
|
||||
timestamp: float # Seconds since epoch (high precision)
|
||||
parameter: str
|
||||
value: float
|
||||
unit: str
|
||||
temperature: float = 0.0 # Chamber temperature at measurement
|
||||
input_voltage: float = 0.0 # DUT input voltage at measurement
|
||||
load_current: float = 0.0 # DUT load current at measurement
|
||||
415
src/py_dvt_ate/data/repository.py
Normal file
415
src/py_dvt_ate/data/repository.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Data repository implementation using SQLite and Parquet.
|
||||
|
||||
This module provides SQLite-based storage for test run metadata and results.
|
||||
Time-series measurements are stored separately in Parquet files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from py_dvt_ate.data.models import Measurement, TestResult, TestRun, TestStatus
|
||||
|
||||
|
||||
class ITestRepository(ABC):
|
||||
"""Repository interface for test data."""
|
||||
|
||||
@abstractmethod
|
||||
def create_run(
|
||||
self,
|
||||
test_name: str,
|
||||
config: dict[str, Any],
|
||||
operator: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> UUID:
|
||||
"""Create a new test run and return its ID."""
|
||||
|
||||
@abstractmethod
|
||||
def update_run_status(self, run_id: UUID, status: TestStatus) -> None:
|
||||
"""Update the status of a test run."""
|
||||
|
||||
@abstractmethod
|
||||
def complete_run(self, run_id: UUID, status: TestStatus) -> None:
|
||||
"""Mark a test run as complete with final status."""
|
||||
|
||||
@abstractmethod
|
||||
def save_result(
|
||||
self,
|
||||
run_id: UUID,
|
||||
parameter: str,
|
||||
value: float,
|
||||
unit: str,
|
||||
lower_limit: float | None = None,
|
||||
upper_limit: float | None = None,
|
||||
) -> None:
|
||||
"""Save a scalar test result."""
|
||||
|
||||
@abstractmethod
|
||||
def save_measurements(
|
||||
self,
|
||||
run_id: UUID,
|
||||
measurements: list[Measurement],
|
||||
) -> None:
|
||||
"""Save time-series measurements (implemented in Parquet extension)."""
|
||||
|
||||
@abstractmethod
|
||||
def get_run(self, run_id: UUID) -> TestRun:
|
||||
"""Retrieve test run metadata by ID."""
|
||||
|
||||
@abstractmethod
|
||||
def get_results(self, run_id: UUID) -> list[TestResult]:
|
||||
"""Retrieve all test results for a run."""
|
||||
|
||||
@abstractmethod
|
||||
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
|
||||
"""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):
|
||||
"""SQLite-based repository for test data.
|
||||
|
||||
Stores test run metadata and scalar results in SQLite.
|
||||
Time-series measurements are stored in Parquet files.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | Path, measurements_dir: str | Path | None = None):
|
||||
"""Initialise repository with database and measurements paths.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file
|
||||
measurements_dir: Directory for Parquet measurement files
|
||||
(defaults to db_path parent / "measurements")
|
||||
"""
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if measurements_dir is None:
|
||||
self.measurements_dir = self.db_path.parent / "measurements"
|
||||
else:
|
||||
self.measurements_dir = Path(measurements_dir)
|
||||
|
||||
self.measurements_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self) -> None:
|
||||
"""Create database tables if they don't exist."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
test_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
config_json TEXT NOT NULL,
|
||||
operator TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS test_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
test_run_id TEXT NOT NULL,
|
||||
parameter TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
unit TEXT,
|
||||
lower_limit REAL,
|
||||
upper_limit REAL,
|
||||
passed INTEGER NOT NULL,
|
||||
measured_at TEXT NOT NULL,
|
||||
FOREIGN KEY (test_run_id) REFERENCES test_runs(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_test_runs_status ON test_runs(status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_test_runs_name ON test_runs(test_name)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_test_results_run ON test_results(test_run_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_test_results_param ON test_results(parameter)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def create_run(
|
||||
self,
|
||||
test_name: str,
|
||||
config: dict[str, Any],
|
||||
operator: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> UUID:
|
||||
"""Create a new test run and return its ID."""
|
||||
run_id = uuid4()
|
||||
started_at = datetime.now()
|
||||
config_json = json.dumps(config)
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO test_runs (
|
||||
id, test_name, description, started_at, status,
|
||||
config_json, operator, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(run_id),
|
||||
test_name,
|
||||
description,
|
||||
started_at.isoformat(),
|
||||
TestStatus.PENDING.value,
|
||||
config_json,
|
||||
operator,
|
||||
datetime.now().isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return run_id
|
||||
|
||||
def update_run_status(self, run_id: UUID, status: TestStatus) -> None:
|
||||
"""Update the status of a test run."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE test_runs SET status = ? WHERE id = ?",
|
||||
(status.value, str(run_id)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def complete_run(self, run_id: UUID, status: TestStatus) -> None:
|
||||
"""Mark a test run as complete with final status."""
|
||||
completed_at = datetime.now()
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE test_runs
|
||||
SET status = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status.value, completed_at.isoformat(), str(run_id)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def save_result(
|
||||
self,
|
||||
run_id: UUID,
|
||||
parameter: str,
|
||||
value: float,
|
||||
unit: str,
|
||||
lower_limit: float | None = None,
|
||||
upper_limit: float | None = None,
|
||||
) -> None:
|
||||
"""Save a scalar test result."""
|
||||
result_id = uuid4()
|
||||
measured_at = datetime.now()
|
||||
|
||||
# Calculate pass/fail
|
||||
passed = 1 # Default to pass if no limits
|
||||
if lower_limit is not None or upper_limit is not None:
|
||||
lower_ok = lower_limit is None or value >= lower_limit
|
||||
upper_ok = upper_limit is None or value <= upper_limit
|
||||
passed = 1 if (lower_ok and upper_ok) else 0
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO test_results (
|
||||
id, test_run_id, parameter, value, unit,
|
||||
lower_limit, upper_limit, passed, measured_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(result_id),
|
||||
str(run_id),
|
||||
parameter,
|
||||
value,
|
||||
unit,
|
||||
lower_limit,
|
||||
upper_limit,
|
||||
passed,
|
||||
measured_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def save_measurements(
|
||||
self,
|
||||
run_id: UUID,
|
||||
measurements: list[Measurement],
|
||||
) -> None:
|
||||
"""Save time-series measurements to Parquet file.
|
||||
|
||||
Measurements are stored in Parquet format for efficient time-series storage.
|
||||
File path: {measurements_dir}/run_{run_id}/measurements.parquet
|
||||
"""
|
||||
if not measurements:
|
||||
return
|
||||
|
||||
# Create run-specific directory
|
||||
run_dir = self.measurements_dir / f"run_{run_id}"
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert measurements to DataFrame
|
||||
data = {
|
||||
"timestamp": [m.timestamp for m in measurements],
|
||||
"parameter": [m.parameter for m in measurements],
|
||||
"value": [m.value for m in measurements],
|
||||
"unit": [m.unit for m in measurements],
|
||||
"temperature": [m.temperature for m in measurements],
|
||||
"input_voltage": [m.input_voltage for m in measurements],
|
||||
"load_current": [m.load_current for m in measurements],
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Save to Parquet (append mode if file exists)
|
||||
parquet_path = run_dir / "measurements.parquet"
|
||||
if parquet_path.exists():
|
||||
# Read existing data and append
|
||||
existing_df = pd.read_parquet(parquet_path)
|
||||
df = pd.concat([existing_df, df], ignore_index=True)
|
||||
|
||||
df.to_parquet(parquet_path, index=False, engine="pyarrow")
|
||||
|
||||
def get_run(self, run_id: UUID) -> TestRun:
|
||||
"""Retrieve test run metadata by ID."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM test_runs WHERE id = ?",
|
||||
(str(run_id),),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
msg = f"Test run {run_id} not found"
|
||||
raise ValueError(msg)
|
||||
|
||||
return TestRun(
|
||||
id=row["id"],
|
||||
test_name=row["test_name"],
|
||||
description=row["description"],
|
||||
started_at=datetime.fromisoformat(row["started_at"]),
|
||||
completed_at=(
|
||||
datetime.fromisoformat(row["completed_at"])
|
||||
if row["completed_at"]
|
||||
else None
|
||||
),
|
||||
status=TestStatus(row["status"]),
|
||||
config_json=row["config_json"],
|
||||
operator=row["operator"],
|
||||
notes=row["notes"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
)
|
||||
|
||||
def get_results(self, run_id: UUID) -> list[TestResult]:
|
||||
"""Retrieve all test results for a run."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM test_results WHERE test_run_id = ?",
|
||||
(str(run_id),),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [
|
||||
TestResult(
|
||||
id=row["id"],
|
||||
test_run_id=row["test_run_id"],
|
||||
parameter=row["parameter"],
|
||||
value=row["value"],
|
||||
unit=row["unit"],
|
||||
lower_limit=row["lower_limit"],
|
||||
upper_limit=row["upper_limit"],
|
||||
measured_at=datetime.fromisoformat(row["measured_at"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
|
||||
"""Retrieve measurements as pandas DataFrame from Parquet file.
|
||||
|
||||
Args:
|
||||
run_id: Test run ID
|
||||
|
||||
Returns:
|
||||
DataFrame with measurement data, or None if no measurements exist
|
||||
"""
|
||||
parquet_path = self.measurements_dir / f"run_{run_id}" / "measurements.parquet"
|
||||
|
||||
if not parquet_path.exists():
|
||||
return None
|
||||
|
||||
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()
|
||||
@@ -3,3 +3,20 @@
|
||||
Provides test sequencing, measurement logging, limit checking,
|
||||
and runtime context management for DVT characterisation tests.
|
||||
"""
|
||||
|
||||
from py_dvt_ate.framework.context import ITest, TestContext
|
||||
from py_dvt_ate.framework.limits import Limit, LimitSet, check_value, evaluate_results
|
||||
from py_dvt_ate.framework.logger import ITestLogger, TestLogger
|
||||
from py_dvt_ate.framework.runner import TestRunner
|
||||
|
||||
__all__ = [
|
||||
"ITest",
|
||||
"ITestLogger",
|
||||
"Limit",
|
||||
"LimitSet",
|
||||
"TestContext",
|
||||
"TestLogger",
|
||||
"TestRunner",
|
||||
"check_value",
|
||||
"evaluate_results",
|
||||
]
|
||||
|
||||
111
src/py_dvt_ate/framework/context.py
Normal file
111
src/py_dvt_ate/framework/context.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Test framework context and interface definitions.
|
||||
|
||||
This module defines the core abstractions for the test executive framework:
|
||||
- TestContext: Runtime context passed to tests during execution
|
||||
- ITest: Abstract base class that all DVT tests must implement
|
||||
|
||||
The test framework orchestrates test execution, measurement logging, and
|
||||
result evaluation against limits.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
from py_dvt_ate.data.models import TestStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Avoid circular imports while maintaining type checking
|
||||
from py_dvt_ate.framework.logger import ITestLogger
|
||||
from py_dvt_ate.instruments.factory import InstrumentSet
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestContext:
|
||||
"""Runtime context for test execution.
|
||||
|
||||
Provides access to instruments, logging, and configuration during test
|
||||
execution. Passed to each test's execute() method.
|
||||
|
||||
Attributes:
|
||||
run_id: Unique identifier for this test run (UUID).
|
||||
instruments: Hardware abstraction layer providing access to all instruments.
|
||||
logger: Test logger for recording measurements and events.
|
||||
config: Test-specific configuration dictionary.
|
||||
"""
|
||||
|
||||
run_id: UUID
|
||||
instruments: "InstrumentSet"
|
||||
logger: "ITestLogger"
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
class ITest(ABC):
|
||||
"""Abstract base class for DVT test implementations.
|
||||
|
||||
All characterisation tests must inherit from this class and implement
|
||||
the required properties and methods. The test runner uses these to
|
||||
discover, describe, and execute tests.
|
||||
|
||||
Example:
|
||||
class TempCoTest(ITest):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "tempco"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Output voltage temperature coefficient"
|
||||
|
||||
def execute(self, context: TestContext) -> TestStatus:
|
||||
# Test implementation...
|
||||
return TestStatus.PASSED
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Return the unique test identifier.
|
||||
|
||||
Used for test discovery and selection. Should be lowercase,
|
||||
alphanumeric with underscores (e.g., "tempco", "load_regulation").
|
||||
|
||||
Returns:
|
||||
Unique test name string.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Return a human-readable test description.
|
||||
|
||||
Describes what the test measures or characterises. Displayed in
|
||||
reports and user interfaces.
|
||||
|
||||
Returns:
|
||||
Brief description of the test purpose.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, context: TestContext) -> TestStatus:
|
||||
"""Execute the test with the given context.
|
||||
|
||||
Contains the test logic: configure instruments, take measurements,
|
||||
log results, and evaluate pass/fail. The test should use the
|
||||
context.logger to record measurements and context.instruments to
|
||||
control equipment.
|
||||
|
||||
Args:
|
||||
context: Runtime context with instruments, logger, and config.
|
||||
|
||||
Returns:
|
||||
Final test status (PASSED, FAILED, ERROR, etc.).
|
||||
|
||||
Raises:
|
||||
Exception: If a critical error occurs during test execution.
|
||||
The test runner will catch this and mark the test as ERROR.
|
||||
"""
|
||||
pass
|
||||
238
src/py_dvt_ate/framework/limits.py
Normal file
238
src/py_dvt_ate/framework/limits.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Limit checking utilities for test result evaluation.
|
||||
|
||||
This module provides utilities for evaluating measurements against specification
|
||||
limits and determining pass/fail status. Used by tests to check if results meet
|
||||
requirements and by the test runner to determine overall test status.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from py_dvt_ate.data.models import TestResult, TestStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limit:
|
||||
"""Specification limit for a parameter.
|
||||
|
||||
Represents a single limit specification with optional lower and upper bounds.
|
||||
Used to define test specifications and evaluate pass/fail.
|
||||
|
||||
Attributes:
|
||||
parameter: Parameter name this limit applies to.
|
||||
lower: Optional lower limit (inclusive). None means no lower limit.
|
||||
upper: Optional upper limit (inclusive). None means no upper limit.
|
||||
unit: Unit of measurement for the limits.
|
||||
|
||||
Example:
|
||||
temp_co_limit = Limit("temp_co", lower=-50.0, upper=50.0, unit="ppm/°C")
|
||||
"""
|
||||
|
||||
parameter: str
|
||||
lower: float | None = None
|
||||
upper: float | None = None
|
||||
unit: str = ""
|
||||
|
||||
def check(self, value: float) -> bool | None:
|
||||
"""Check if a value is within this limit.
|
||||
|
||||
Args:
|
||||
value: Value to check against limits.
|
||||
|
||||
Returns:
|
||||
True if value is within limits, False if outside limits.
|
||||
None if no limits are defined (informational parameter).
|
||||
|
||||
Example:
|
||||
limit = Limit("v_out", lower=3.25, upper=3.35, unit="V")
|
||||
limit.check(3.30) # Returns True
|
||||
limit.check(3.40) # Returns False
|
||||
"""
|
||||
if self.lower is None and self.upper is None:
|
||||
return None
|
||||
|
||||
lower_ok = self.lower is None or value >= self.lower
|
||||
upper_ok = self.upper is None or value <= self.upper
|
||||
return lower_ok and upper_ok
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LimitSet:
|
||||
"""Collection of limits for a test.
|
||||
|
||||
Groups multiple parameter limits together as a test specification.
|
||||
Can be loaded from configuration or defined programmatically.
|
||||
|
||||
Attributes:
|
||||
name: Name of this limit set (e.g., "nominal", "extended").
|
||||
limits: Dictionary mapping parameter names to Limit objects.
|
||||
|
||||
Example:
|
||||
limits = LimitSet(
|
||||
name="nominal",
|
||||
limits={
|
||||
"temp_co": Limit("temp_co", -50.0, 50.0, "ppm/°C"),
|
||||
"v_out": Limit("v_out", 3.25, 3.35, "V"),
|
||||
}
|
||||
)
|
||||
"""
|
||||
|
||||
name: str
|
||||
limits: dict[str, Limit]
|
||||
|
||||
def get_limit(self, parameter: str) -> Limit | None:
|
||||
"""Get the limit for a specific parameter.
|
||||
|
||||
Args:
|
||||
parameter: Parameter name to look up.
|
||||
|
||||
Returns:
|
||||
Limit object if found, None if parameter has no limit defined.
|
||||
"""
|
||||
return self.limits.get(parameter)
|
||||
|
||||
def check(self, parameter: str, value: float) -> bool | None:
|
||||
"""Check if a value is within limits for a parameter.
|
||||
|
||||
Args:
|
||||
parameter: Parameter name.
|
||||
value: Value to check.
|
||||
|
||||
Returns:
|
||||
True if within limits, False if outside limits.
|
||||
None if parameter has no limit defined.
|
||||
"""
|
||||
limit = self.get_limit(parameter)
|
||||
if limit is None:
|
||||
return None
|
||||
return limit.check(value)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, limits_dict: dict[str, Any]) -> "LimitSet":
|
||||
"""Create a LimitSet from a dictionary.
|
||||
|
||||
Useful for loading limit sets from YAML configuration files.
|
||||
|
||||
Args:
|
||||
name: Name for this limit set.
|
||||
limits_dict: Dictionary with parameter names as keys and limit
|
||||
specifications as values. Each limit spec should have:
|
||||
- "lower": Optional lower limit
|
||||
- "upper": Optional upper limit
|
||||
- "unit": Unit of measurement
|
||||
|
||||
Returns:
|
||||
LimitSet instance.
|
||||
|
||||
Example:
|
||||
config = {
|
||||
"temp_co": {"lower": -50.0, "upper": 50.0, "unit": "ppm/°C"},
|
||||
"v_out": {"lower": 3.25, "upper": 3.35, "unit": "V"},
|
||||
}
|
||||
limits = LimitSet.from_dict("nominal", config)
|
||||
"""
|
||||
limits = {}
|
||||
for param, spec in limits_dict.items():
|
||||
limits[param] = Limit(
|
||||
parameter=param,
|
||||
lower=spec.get("lower"),
|
||||
upper=spec.get("upper"),
|
||||
unit=spec.get("unit", ""),
|
||||
)
|
||||
return cls(name=name, limits=limits)
|
||||
|
||||
|
||||
def check_value(
|
||||
value: float,
|
||||
lower: float | None = None,
|
||||
upper: float | None = None,
|
||||
) -> bool | None:
|
||||
"""Check if a value is within specified limits.
|
||||
|
||||
Utility function for quick limit checking without creating Limit objects.
|
||||
|
||||
Args:
|
||||
value: Value to check.
|
||||
lower: Optional lower limit (inclusive).
|
||||
upper: Optional upper limit (inclusive).
|
||||
|
||||
Returns:
|
||||
True if value is within limits, False if outside limits.
|
||||
None if no limits are specified.
|
||||
|
||||
Example:
|
||||
check_value(3.30, lower=3.25, upper=3.35) # Returns True
|
||||
check_value(3.40, lower=3.25, upper=3.35) # Returns False
|
||||
check_value(3.30) # Returns None (no limits)
|
||||
"""
|
||||
if lower is None and upper is None:
|
||||
return None
|
||||
|
||||
lower_ok = lower is None or value >= lower
|
||||
upper_ok = upper is None or value <= upper
|
||||
return lower_ok and upper_ok
|
||||
|
||||
|
||||
def evaluate_results(results: list[TestResult]) -> TestStatus:
|
||||
"""Evaluate a list of test results to determine overall status.
|
||||
|
||||
Aggregates multiple test results into a single pass/fail determination.
|
||||
If any result fails its limits, the overall status is FAILED.
|
||||
If all results pass (or have no limits), the overall status is PASSED.
|
||||
|
||||
Args:
|
||||
results: List of TestResult objects to evaluate.
|
||||
|
||||
Returns:
|
||||
TestStatus.PASSED if all results pass their limits.
|
||||
TestStatus.FAILED if any result fails its limits.
|
||||
TestStatus.PASSED if no results have limits defined (informational only).
|
||||
|
||||
Example:
|
||||
results = [
|
||||
TestResult(..., value=25.0, lower_limit=-50.0, upper_limit=50.0),
|
||||
TestResult(..., value=3.30, lower_limit=3.25, upper_limit=3.35),
|
||||
]
|
||||
status = evaluate_results(results) # Returns TestStatus.PASSED
|
||||
"""
|
||||
if not results:
|
||||
return TestStatus.PASSED
|
||||
|
||||
# Check if any result failed
|
||||
for result in results:
|
||||
if result.passed is False:
|
||||
return TestStatus.FAILED
|
||||
|
||||
# All results passed (or had no limits)
|
||||
return TestStatus.PASSED
|
||||
|
||||
|
||||
def format_limit_violation(result: TestResult) -> str:
|
||||
"""Format a limit violation message for a failed result.
|
||||
|
||||
Creates a human-readable message describing why a result failed.
|
||||
Useful for logging and reporting.
|
||||
|
||||
Args:
|
||||
result: TestResult that failed its limits.
|
||||
|
||||
Returns:
|
||||
Formatted violation message.
|
||||
|
||||
Example:
|
||||
result = TestResult(..., parameter="v_out", value=3.40,
|
||||
lower_limit=3.25, upper_limit=3.35, unit="V")
|
||||
message = format_limit_violation(result)
|
||||
# Returns: "v_out: 3.400 V [FAIL] (limits: 3.250 to 3.350 V)"
|
||||
"""
|
||||
status = "PASS" if result.passed else "FAIL"
|
||||
limits_str = ""
|
||||
|
||||
if result.lower_limit is not None and result.upper_limit is not None:
|
||||
limits_str = f" (limits: {result.lower_limit:.3f} to {result.upper_limit:.3f} {result.unit})"
|
||||
elif result.lower_limit is not None:
|
||||
limits_str = f" (minimum: {result.lower_limit:.3f} {result.unit})"
|
||||
elif result.upper_limit is not None:
|
||||
limits_str = f" (maximum: {result.upper_limit:.3f} {result.unit})"
|
||||
|
||||
return f"{result.parameter}: {result.value:.3f} {result.unit} [{status}]{limits_str}"
|
||||
222
src/py_dvt_ate/framework/logger.py
Normal file
222
src/py_dvt_ate/framework/logger.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Test logger for recording measurements and events.
|
||||
|
||||
This module provides the logging infrastructure for DVT tests. The test logger
|
||||
records time-series measurements, scalar results with limits, and event messages
|
||||
during test execution.
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from py_dvt_ate.data.models import Measurement
|
||||
from py_dvt_ate.data.repository import ITestRepository
|
||||
|
||||
|
||||
class ITestLogger(ABC):
|
||||
"""Abstract interface for test data logging.
|
||||
|
||||
Provides methods for logging measurements, results, and events during
|
||||
test execution. Implementations are responsible for persisting this
|
||||
data to the appropriate storage backend.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def log_measurement(
|
||||
self,
|
||||
parameter: str,
|
||||
value: float,
|
||||
unit: str,
|
||||
conditions: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""Log a time-series measurement with environmental conditions.
|
||||
|
||||
Used for logging raw measurements taken during the test. These are
|
||||
stored as time-series data for later analysis and plotting.
|
||||
|
||||
Args:
|
||||
parameter: Measurement parameter name (e.g., "v_out", "i_q").
|
||||
value: Measured value.
|
||||
unit: Unit of measurement (e.g., "V", "A", "°C").
|
||||
conditions: Optional environmental conditions at time of measurement:
|
||||
- "temperature": Chamber temperature (°C)
|
||||
- "input_voltage": DUT input voltage (V)
|
||||
- "load_current": DUT load current (A)
|
||||
|
||||
Example:
|
||||
logger.log_measurement(
|
||||
"v_out", 3.301, "V",
|
||||
conditions={"temperature": 25.0, "input_voltage": 5.0}
|
||||
)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def log_result(
|
||||
self,
|
||||
parameter: str,
|
||||
value: float,
|
||||
unit: str,
|
||||
lower_limit: float | None = None,
|
||||
upper_limit: float | None = None,
|
||||
) -> None:
|
||||
"""Log a scalar test result with pass/fail limits.
|
||||
|
||||
Used for logging calculated or derived results that will be evaluated
|
||||
against specification limits. These appear in test reports and determine
|
||||
overall pass/fail status.
|
||||
|
||||
Args:
|
||||
parameter: Result parameter name (e.g., "temp_co", "load_reg").
|
||||
value: Calculated or measured value.
|
||||
unit: Unit of measurement (e.g., "ppm/°C", "%", "mV").
|
||||
lower_limit: Optional lower limit for pass/fail evaluation.
|
||||
upper_limit: Optional upper limit for pass/fail evaluation.
|
||||
|
||||
Example:
|
||||
logger.log_result(
|
||||
"temp_co", 23.5, "ppm/°C",
|
||||
lower_limit=-50.0, upper_limit=50.0
|
||||
)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def log_event(self, message: str, level: str = "INFO") -> None:
|
||||
"""Log a test event or message.
|
||||
|
||||
Used for logging informational messages, warnings, and errors during
|
||||
test execution. Useful for debugging and understanding test flow.
|
||||
|
||||
Args:
|
||||
message: Event message text.
|
||||
level: Log level ("DEBUG", "INFO", "WARNING", "ERROR").
|
||||
|
||||
Example:
|
||||
logger.log_event("Waiting for thermal stability", level="INFO")
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def flush(self) -> None:
|
||||
"""Flush any buffered data to storage.
|
||||
|
||||
Forces any buffered measurements or results to be written to the
|
||||
underlying storage backend. Called automatically at end of test,
|
||||
but can be called manually for long-running tests.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class TestLogger(ITestLogger):
|
||||
"""Concrete test logger implementation using repository pattern.
|
||||
|
||||
Buffers measurements in memory and writes them in batches to a
|
||||
repository for efficiency. Results and events are written immediately.
|
||||
|
||||
Attributes:
|
||||
run_id: UUID of the test run this logger is associated with.
|
||||
repository: Data repository for persisting measurements and results.
|
||||
measurement_buffer: In-memory buffer of measurements awaiting write.
|
||||
buffer_size: Number of measurements to buffer before auto-flush.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: UUID,
|
||||
repository: ITestRepository,
|
||||
buffer_size: int = 100,
|
||||
):
|
||||
"""Initialise test logger.
|
||||
|
||||
Args:
|
||||
run_id: UUID of the test run to associate logs with.
|
||||
repository: Repository for persisting data.
|
||||
buffer_size: Number of measurements to buffer before auto-flush.
|
||||
Default 100 provides good balance of performance
|
||||
and memory usage.
|
||||
"""
|
||||
self.run_id = run_id
|
||||
self.repository = repository
|
||||
self.buffer_size = buffer_size
|
||||
self.measurement_buffer: list[Measurement] = []
|
||||
|
||||
def log_measurement(
|
||||
self,
|
||||
parameter: str,
|
||||
value: float,
|
||||
unit: str,
|
||||
conditions: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""Log a time-series measurement with environmental conditions.
|
||||
|
||||
Measurements are buffered in memory and written to the repository
|
||||
in batches for efficiency.
|
||||
"""
|
||||
conditions = conditions or {}
|
||||
measurement = Measurement(
|
||||
timestamp=time.time(),
|
||||
parameter=parameter,
|
||||
value=value,
|
||||
unit=unit,
|
||||
temperature=conditions.get("temperature", 0.0),
|
||||
input_voltage=conditions.get("input_voltage", 0.0),
|
||||
load_current=conditions.get("load_current", 0.0),
|
||||
)
|
||||
self.measurement_buffer.append(measurement)
|
||||
|
||||
# Auto-flush when buffer is full
|
||||
if len(self.measurement_buffer) >= self.buffer_size:
|
||||
self.flush()
|
||||
|
||||
def log_result(
|
||||
self,
|
||||
parameter: str,
|
||||
value: float,
|
||||
unit: str,
|
||||
lower_limit: float | None = None,
|
||||
upper_limit: float | None = None,
|
||||
) -> None:
|
||||
"""Log a scalar test result with pass/fail limits.
|
||||
|
||||
Results are written immediately to the repository (not buffered).
|
||||
"""
|
||||
self.repository.save_result(
|
||||
run_id=self.run_id,
|
||||
parameter=parameter,
|
||||
value=value,
|
||||
unit=unit,
|
||||
lower_limit=lower_limit,
|
||||
upper_limit=upper_limit,
|
||||
)
|
||||
|
||||
def log_event(self, message: str, level: str = "INFO") -> None:
|
||||
"""Log a test event or message.
|
||||
|
||||
Events are currently logged to console. Future versions may
|
||||
persist events to the repository.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
print(f"[{timestamp}] {level:7s} {message}")
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush buffered measurements to repository.
|
||||
|
||||
Writes all buffered measurements to the repository in a single
|
||||
batch operation, then clears the buffer.
|
||||
"""
|
||||
if self.measurement_buffer:
|
||||
self.repository.save_measurements(
|
||||
run_id=self.run_id,
|
||||
measurements=self.measurement_buffer,
|
||||
)
|
||||
self.measurement_buffer.clear()
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Ensure buffered data is flushed on logger destruction."""
|
||||
try:
|
||||
self.flush()
|
||||
except Exception:
|
||||
# Ignore errors during cleanup
|
||||
pass
|
||||
203
src/py_dvt_ate/framework/runner.py
Normal file
203
src/py_dvt_ate/framework/runner.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Test runner for orchestrating DVT test execution.
|
||||
|
||||
This module provides the TestRunner class, which coordinates test execution,
|
||||
manages test lifecycle, and ensures proper logging and error handling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import traceback
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from py_dvt_ate.data.models import TestStatus
|
||||
from py_dvt_ate.data.repository import ITestRepository
|
||||
from py_dvt_ate.framework.context import ITest, TestContext
|
||||
from py_dvt_ate.framework.limits import evaluate_results
|
||||
from py_dvt_ate.framework.logger import TestLogger
|
||||
from py_dvt_ate.instruments.factory import InstrumentSet
|
||||
|
||||
|
||||
class TestRunner:
|
||||
"""Orchestrates DVT test execution.
|
||||
|
||||
The test runner manages the complete test lifecycle:
|
||||
1. Creates a test run record in the repository
|
||||
2. Sets up logging and context
|
||||
3. Executes the test with proper error handling
|
||||
4. Evaluates results against limits
|
||||
5. Updates final status and flushes data
|
||||
|
||||
Attributes:
|
||||
repository: Data repository for persisting test results.
|
||||
|
||||
Example:
|
||||
runner = TestRunner(repository)
|
||||
instruments = factory.create(config)
|
||||
run_id = runner.run_test(
|
||||
test=TempCoTest(),
|
||||
instruments=instruments,
|
||||
config={"temp_points": [-40, 25, 85]},
|
||||
operator="alice@example.com"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, repository: ITestRepository):
|
||||
"""Initialise test runner.
|
||||
|
||||
Args:
|
||||
repository: Repository for persisting test data.
|
||||
"""
|
||||
self.repository = repository
|
||||
|
||||
def run_test(
|
||||
self,
|
||||
test: ITest,
|
||||
instruments: InstrumentSet,
|
||||
config: dict[str, Any] | None = None,
|
||||
operator: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> UUID:
|
||||
"""Run a DVT test with full lifecycle management.
|
||||
|
||||
Creates a test run, executes the test with proper error handling,
|
||||
evaluates results, and updates final status. All measurements and
|
||||
results are persisted to the repository.
|
||||
|
||||
Args:
|
||||
test: Test instance to execute (implements ITest).
|
||||
instruments: Instrument set for test to use.
|
||||
config: Optional test-specific configuration dictionary.
|
||||
operator: Optional operator identifier (e.g., email address).
|
||||
description: Optional human-readable test run description.
|
||||
|
||||
Returns:
|
||||
UUID of the test run. Can be used to retrieve results later.
|
||||
|
||||
Raises:
|
||||
Exception: Only if repository operations fail. Test execution
|
||||
errors are caught and recorded as ERROR status.
|
||||
|
||||
Example:
|
||||
run_id = runner.run_test(
|
||||
test=TempCoTest(),
|
||||
instruments=instruments,
|
||||
config={"temp_points": [-40, 25, 85]},
|
||||
operator="alice@example.com",
|
||||
description="Characterisation run #42"
|
||||
)
|
||||
print(f"Test run ID: {run_id}")
|
||||
"""
|
||||
config = config or {}
|
||||
|
||||
# Create test run record
|
||||
run_id = self.repository.create_run(
|
||||
test_name=test.name,
|
||||
config=config,
|
||||
operator=operator,
|
||||
description=description or test.description,
|
||||
)
|
||||
|
||||
# Create logger for this run
|
||||
logger = TestLogger(run_id=run_id, repository=self.repository)
|
||||
|
||||
# Create test context
|
||||
context = TestContext(
|
||||
run_id=run_id,
|
||||
instruments=instruments,
|
||||
logger=logger,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Update status to running
|
||||
self.repository.update_run_status(run_id, TestStatus.RUNNING)
|
||||
|
||||
# Execute test with error handling
|
||||
try:
|
||||
logger.log_event(f"Starting test: {test.name}", level="INFO")
|
||||
logger.log_event(f"Description: {test.description}", level="INFO")
|
||||
|
||||
# Log configuration
|
||||
if config:
|
||||
config_str = json.dumps(config, indent=2)
|
||||
logger.log_event(f"Configuration:\n{config_str}", level="DEBUG")
|
||||
|
||||
# Execute the test
|
||||
status = test.execute(context)
|
||||
|
||||
# Flush any buffered measurements
|
||||
logger.flush()
|
||||
|
||||
# Evaluate results if test didn't explicitly set status
|
||||
if status == TestStatus.RUNNING:
|
||||
results = self.repository.get_results(run_id)
|
||||
status = evaluate_results(results)
|
||||
logger.log_event(
|
||||
f"Test completed. Evaluated {len(results)} results: {status.value}",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
# Update final status
|
||||
self.repository.complete_run(run_id, status)
|
||||
logger.log_event(f"Test finished with status: {status.value}", level="INFO")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# User interrupted - mark as error but don't swallow interrupt
|
||||
logger.log_event("Test interrupted by user", level="WARNING")
|
||||
logger.flush()
|
||||
self.repository.complete_run(run_id, TestStatus.ERROR)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# Test execution error - log and mark as ERROR
|
||||
error_msg = f"Test execution failed: {e}"
|
||||
logger.log_event(error_msg, level="ERROR")
|
||||
logger.log_event(traceback.format_exc(), level="DEBUG")
|
||||
logger.flush()
|
||||
self.repository.complete_run(run_id, TestStatus.ERROR)
|
||||
logger.log_event("Test finished with status: ERROR", level="INFO")
|
||||
|
||||
return run_id
|
||||
|
||||
def run_tests(
|
||||
self,
|
||||
tests: list[ITest],
|
||||
instruments: InstrumentSet,
|
||||
config: dict[str, Any] | None = None,
|
||||
operator: str | None = None,
|
||||
) -> list[UUID]:
|
||||
"""Run multiple tests sequentially.
|
||||
|
||||
Convenience method for running a suite of tests. Each test is run
|
||||
independently with its own test run record. If one test fails, the
|
||||
remaining tests still execute.
|
||||
|
||||
Args:
|
||||
tests: List of test instances to execute.
|
||||
instruments: Instrument set shared by all tests.
|
||||
config: Optional configuration applied to all tests.
|
||||
operator: Optional operator identifier.
|
||||
|
||||
Returns:
|
||||
List of test run UUIDs in execution order.
|
||||
|
||||
Example:
|
||||
run_ids = runner.run_tests(
|
||||
tests=[TempCoTest(), LoadRegTest(), LineRegTest()],
|
||||
instruments=instruments,
|
||||
config={"common_setting": 42},
|
||||
operator="alice@example.com"
|
||||
)
|
||||
for run_id in run_ids:
|
||||
run = repository.get_run(run_id)
|
||||
print(f"{run.test_name}: {run.status.value}")
|
||||
"""
|
||||
run_ids = []
|
||||
for test in tests:
|
||||
run_id = self.run_test(
|
||||
test=test,
|
||||
instruments=instruments,
|
||||
config=config,
|
||||
operator=operator,
|
||||
)
|
||||
run_ids.append(run_id)
|
||||
return run_ids
|
||||
@@ -7,3 +7,23 @@ This package provides everything needed to communicate with lab instruments:
|
||||
- Instrument drivers
|
||||
- Factory for creating configured instrument sets
|
||||
"""
|
||||
|
||||
from py_dvt_ate.instruments.factory import (
|
||||
InstrumentConfig,
|
||||
InstrumentFactory,
|
||||
InstrumentSet,
|
||||
)
|
||||
from py_dvt_ate.instruments.interfaces import (
|
||||
IMultimeter,
|
||||
IPowerSupply,
|
||||
IThermalChamber,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"IThermalChamber",
|
||||
"IPowerSupply",
|
||||
"IMultimeter",
|
||||
"InstrumentSet",
|
||||
"InstrumentConfig",
|
||||
"InstrumentFactory",
|
||||
]
|
||||
|
||||
@@ -3,3 +3,15 @@
|
||||
Each driver translates high-level operations into SCPI commands
|
||||
and handles responses from instruments.
|
||||
"""
|
||||
|
||||
from py_dvt_ate.instruments.drivers.base import BaseDriver
|
||||
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
|
||||
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
|
||||
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
|
||||
|
||||
__all__ = [
|
||||
"BaseDriver",
|
||||
"ThermalChamberDriver",
|
||||
"PowerSupplyDriver",
|
||||
"MultimeterDriver",
|
||||
]
|
||||
|
||||
197
src/py_dvt_ate/instruments/drivers/base.py
Normal file
197
src/py_dvt_ate/instruments/drivers/base.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Base class for SCPI instrument drivers.
|
||||
|
||||
This module provides the foundation for implementing client-side instrument
|
||||
drivers that communicate via SCPI commands over a transport layer.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py_dvt_ate.instruments.transport.base import Transport
|
||||
|
||||
|
||||
class BaseDriver:
|
||||
"""Base class for SCPI instrument drivers.
|
||||
|
||||
Provides common functionality for communicating with instruments via
|
||||
SCPI commands. Subclasses implement instrument-specific command methods.
|
||||
|
||||
All drivers depend on a Transport instance for low-level communication.
|
||||
|
||||
Attributes:
|
||||
transport: The transport layer for communication.
|
||||
"""
|
||||
|
||||
def __init__(self, transport: "Transport") -> None:
|
||||
"""Initialise the driver with a transport layer.
|
||||
|
||||
Args:
|
||||
transport: Transport instance for communication (TCP, VISA, etc.).
|
||||
"""
|
||||
self.transport = transport
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Establish connection to the instrument.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If connection fails.
|
||||
"""
|
||||
self.transport.connect()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close connection to the instrument.
|
||||
|
||||
Safe to call multiple times (idempotent).
|
||||
"""
|
||||
self.transport.disconnect()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connection is active.
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise.
|
||||
"""
|
||||
return self.transport.is_connected
|
||||
|
||||
def write(self, command: str) -> None:
|
||||
"""Send a SCPI command to the instrument.
|
||||
|
||||
Args:
|
||||
command: SCPI command string (without terminator).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If write fails.
|
||||
"""
|
||||
self.transport.write(command)
|
||||
|
||||
def query(self, command: str, timeout: float | None = None) -> str:
|
||||
"""Send a SCPI query and read the response.
|
||||
|
||||
Args:
|
||||
command: SCPI query string (without terminator).
|
||||
timeout: Read timeout in seconds. None uses default.
|
||||
|
||||
Returns:
|
||||
Response string from instrument.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
return self.transport.query(command, timeout)
|
||||
|
||||
def query_float(self, command: str, timeout: float | None = None) -> float:
|
||||
"""Send a SCPI query and parse response as float.
|
||||
|
||||
Args:
|
||||
command: SCPI query string.
|
||||
timeout: Read timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Parsed floating-point value.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If communication fails.
|
||||
ValueError: If response cannot be parsed as float.
|
||||
"""
|
||||
response = self.query(command, timeout)
|
||||
try:
|
||||
return float(response.strip())
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Cannot parse '{response}' as float") from err
|
||||
|
||||
def query_int(self, command: str, timeout: float | None = None) -> int:
|
||||
"""Send a SCPI query and parse response as integer.
|
||||
|
||||
Args:
|
||||
command: SCPI query string.
|
||||
timeout: Read timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Parsed integer value.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If communication fails.
|
||||
ValueError: If response cannot be parsed as integer.
|
||||
"""
|
||||
response = self.query(command, timeout)
|
||||
try:
|
||||
return int(response.strip())
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Cannot parse '{response}' as int") from err
|
||||
|
||||
def query_bool(self, command: str, timeout: float | None = None) -> bool:
|
||||
"""Send a SCPI query and parse response as boolean.
|
||||
|
||||
Interprets "1", "ON", "TRUE" as True; "0", "OFF", "FALSE" as False.
|
||||
|
||||
Args:
|
||||
command: SCPI query string.
|
||||
timeout: Read timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Parsed boolean value.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If communication fails.
|
||||
ValueError: If response cannot be parsed as boolean.
|
||||
"""
|
||||
response = self.query(command, timeout).strip().upper()
|
||||
if response in ("1", "ON", "TRUE"):
|
||||
return True
|
||||
if response in ("0", "OFF", "FALSE"):
|
||||
return False
|
||||
raise ValueError(f"Cannot parse '{response}' as bool")
|
||||
|
||||
def identify(self) -> str:
|
||||
"""Query instrument identification (*IDN?).
|
||||
|
||||
Returns:
|
||||
Identification string in format:
|
||||
"Manufacturer,Model,SerialNumber,FirmwareVersion"
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
return self.query("*IDN?")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset instrument to default state (*RST).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
self.write("*RST")
|
||||
|
||||
def clear_status(self) -> None:
|
||||
"""Clear instrument status (*CLS).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
self.write("*CLS")
|
||||
|
||||
def operation_complete(self) -> bool:
|
||||
"""Query operation complete status (*OPC?).
|
||||
|
||||
Returns:
|
||||
True if operation complete.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
response = self.query("*OPC?")
|
||||
return response.strip() == "1"
|
||||
142
src/py_dvt_ate/instruments/drivers/chamber.py
Normal file
142
src/py_dvt_ate/instruments/drivers/chamber.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Thermal chamber SCPI driver.
|
||||
|
||||
This module implements a client-side driver for thermal chambers that
|
||||
communicate via SCPI commands.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from py_dvt_ate.instruments.drivers.base import BaseDriver
|
||||
from py_dvt_ate.instruments.interfaces import IThermalChamber
|
||||
|
||||
|
||||
class ThermalChamberDriver(BaseDriver, IThermalChamber):
|
||||
"""SCPI driver for thermal chambers.
|
||||
|
||||
Provides high-level Python API for controlling thermal chambers via
|
||||
SCPI commands. Implements the IThermalChamber interface.
|
||||
|
||||
SCPI Commands Used:
|
||||
TEMP:SETPOINT <value> - Set target temperature (°C)
|
||||
TEMP:SETPOINT? - Query current setpoint
|
||||
TEMP:ACTUAL? - Query actual chamber temperature
|
||||
TEMP:STAB? - Query stability (1=stable, 0=settling)
|
||||
TEMP:RAMP <rate> - Set temperature ramp rate (°C/min)
|
||||
TEMP:RAMP? - Query ramp rate
|
||||
|
||||
Example:
|
||||
>>> transport = TCPTransport("localhost", 5001)
|
||||
>>> chamber = ThermalChamberDriver(transport)
|
||||
>>> chamber.connect()
|
||||
>>> chamber.set_temperature(85.0)
|
||||
>>> chamber.wait_until_stable(timeout=600.0)
|
||||
>>> temp = chamber.get_temperature()
|
||||
"""
|
||||
|
||||
def set_temperature(self, setpoint: float) -> None:
|
||||
"""Set the chamber temperature setpoint.
|
||||
|
||||
Args:
|
||||
setpoint: Target temperature in degrees Celsius.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
self.write(f"TEMP:SETPOINT {setpoint:.2f}")
|
||||
|
||||
def get_temperature(self) -> float:
|
||||
"""Get the actual chamber temperature.
|
||||
|
||||
Returns:
|
||||
Current chamber temperature in degrees Celsius.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("TEMP:ACTUAL?")
|
||||
|
||||
def get_setpoint(self) -> float:
|
||||
"""Get the current temperature setpoint.
|
||||
|
||||
Returns:
|
||||
Current setpoint in degrees Celsius.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("TEMP:SETPOINT?")
|
||||
|
||||
def is_stable(self) -> bool:
|
||||
"""Check if chamber temperature is stable.
|
||||
|
||||
Temperature is considered stable when it has settled within
|
||||
the instrument's configured stability threshold of the setpoint.
|
||||
|
||||
Returns:
|
||||
True if temperature is stable, False if still settling.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_bool("TEMP:STAB?")
|
||||
|
||||
def wait_until_stable(
|
||||
self, timeout: float = 300.0, poll_interval: float = 1.0
|
||||
) -> bool:
|
||||
"""Wait until chamber temperature stabilises.
|
||||
|
||||
Polls the stability status at regular intervals until stable
|
||||
or timeout is reached.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds. Default 300s (5 minutes).
|
||||
poll_interval: Time between stability checks in seconds. Default 1s.
|
||||
|
||||
Returns:
|
||||
True if temperature stabilised within timeout, False if timed out.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If communication fails.
|
||||
ValueError: If timeout or poll_interval are negative.
|
||||
"""
|
||||
if timeout < 0:
|
||||
raise ValueError("Timeout must be non-negative")
|
||||
if poll_interval <= 0:
|
||||
raise ValueError("Poll interval must be positive")
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if self.is_stable():
|
||||
return True
|
||||
time.sleep(poll_interval)
|
||||
|
||||
return False
|
||||
|
||||
def set_ramp_rate(self, rate: float) -> None:
|
||||
"""Set the temperature ramp rate.
|
||||
|
||||
Args:
|
||||
rate: Ramp rate in degrees Celsius per minute.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
self.write(f"TEMP:RAMP {rate:.2f}")
|
||||
|
||||
def get_ramp_rate(self) -> float:
|
||||
"""Get the current temperature ramp rate.
|
||||
|
||||
Returns:
|
||||
Ramp rate in degrees Celsius per minute.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("TEMP:RAMP?")
|
||||
158
src/py_dvt_ate/instruments/drivers/multimeter.py
Normal file
158
src/py_dvt_ate/instruments/drivers/multimeter.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Multimeter SCPI driver.
|
||||
|
||||
This module implements a client-side driver for digital multimeters
|
||||
that communicate via SCPI commands.
|
||||
"""
|
||||
|
||||
from py_dvt_ate.instruments.drivers.base import BaseDriver
|
||||
from py_dvt_ate.instruments.interfaces import IMultimeter
|
||||
|
||||
|
||||
class MultimeterDriver(BaseDriver, IMultimeter):
|
||||
"""SCPI driver for digital multimeters.
|
||||
|
||||
Provides high-level Python API for making measurements with DMMs via
|
||||
SCPI commands. Implements the IMultimeter interface.
|
||||
|
||||
SCPI Commands Used:
|
||||
MEAS:VOLT:DC? - Measure DC voltage
|
||||
MEAS:CURR:DC? - Measure DC current
|
||||
CONF:VOLT:DC - Configure for DC voltage measurement
|
||||
CONF:CURR:DC - Configure for DC current measurement
|
||||
CONF? - Query current configuration
|
||||
READ? - Take measurement with current configuration
|
||||
|
||||
Example:
|
||||
>>> transport = TCPTransport("localhost", 5003)
|
||||
>>> dmm = MultimeterDriver(transport)
|
||||
>>> dmm.connect()
|
||||
>>> voltage = dmm.measure_dc_voltage()
|
||||
>>> current = dmm.measure_dc_current()
|
||||
"""
|
||||
|
||||
def measure_dc_voltage(self, range: str = "AUTO") -> float:
|
||||
"""Measure DC voltage.
|
||||
|
||||
Configures the meter for DC voltage and takes a measurement.
|
||||
|
||||
Args:
|
||||
range: Measurement range. Default "AUTO" for auto-ranging.
|
||||
Note: Range parameter currently not supported by simulator.
|
||||
|
||||
Returns:
|
||||
Measured voltage in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
# Note: Range parameter not yet implemented in virtual instrument
|
||||
return self.query_float("MEAS:VOLT:DC?")
|
||||
|
||||
def measure_dc_current(self, range: str = "AUTO") -> float:
|
||||
"""Measure DC current.
|
||||
|
||||
Configures the meter for DC current and takes a measurement.
|
||||
|
||||
Args:
|
||||
range: Measurement range. Default "AUTO" for auto-ranging.
|
||||
Note: Range parameter currently not supported by simulator.
|
||||
|
||||
Returns:
|
||||
Measured current in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
# Note: Range parameter not yet implemented in virtual instrument
|
||||
return self.query_float("MEAS:CURR:DC?")
|
||||
|
||||
def measure_resistance(self, range: str = "AUTO") -> float:
|
||||
"""Measure resistance.
|
||||
|
||||
Configures the meter for resistance and takes a measurement.
|
||||
|
||||
Args:
|
||||
range: Measurement range. Default "AUTO" for auto-ranging.
|
||||
|
||||
Returns:
|
||||
Measured resistance in ohms.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
NotImplementedError: If instrument does not support resistance.
|
||||
"""
|
||||
# Note: Resistance measurement not yet implemented in virtual instrument
|
||||
raise NotImplementedError(
|
||||
"Resistance measurement not yet supported by virtual instrument"
|
||||
)
|
||||
|
||||
def set_integration_time(self, nplc: float) -> None:
|
||||
"""Set the integration time.
|
||||
|
||||
Args:
|
||||
nplc: Integration time in number of power line cycles (NPLC).
|
||||
Typical values: 0.02, 0.2, 1, 10, 100.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
NotImplementedError: If instrument does not support integration time.
|
||||
"""
|
||||
# Note: Integration time not yet implemented in virtual instrument
|
||||
raise NotImplementedError(
|
||||
"Integration time setting not yet supported by virtual instrument"
|
||||
)
|
||||
|
||||
def configure_dc_voltage(self) -> None:
|
||||
"""Configure meter for DC voltage measurement.
|
||||
|
||||
Sets the measurement function without taking a measurement.
|
||||
Use read() to take a measurement after configuring.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
self.write("CONF:VOLT:DC")
|
||||
|
||||
def configure_dc_current(self) -> None:
|
||||
"""Configure meter for DC current measurement.
|
||||
|
||||
Sets the measurement function without taking a measurement.
|
||||
Use read() to take a measurement after configuring.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
self.write("CONF:CURR:DC")
|
||||
|
||||
def get_configuration(self) -> str:
|
||||
"""Get the current measurement configuration.
|
||||
|
||||
Returns:
|
||||
Configuration string (e.g., "VOLT:DC", "CURR:DC").
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query("CONF?").strip('"')
|
||||
|
||||
def read(self) -> float:
|
||||
"""Take a measurement using the current configuration.
|
||||
|
||||
Must call configure_dc_voltage() or configure_dc_current() first
|
||||
to set the measurement function.
|
||||
|
||||
Returns:
|
||||
Measured value (voltage in V or current in A).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("READ?")
|
||||
153
src/py_dvt_ate/instruments/drivers/power_supply.py
Normal file
153
src/py_dvt_ate/instruments/drivers/power_supply.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Power supply SCPI driver.
|
||||
|
||||
This module implements a client-side driver for programmable power supplies
|
||||
that communicate via SCPI commands.
|
||||
"""
|
||||
|
||||
from py_dvt_ate.instruments.drivers.base import BaseDriver
|
||||
from py_dvt_ate.instruments.interfaces import IPowerSupply
|
||||
|
||||
|
||||
class PowerSupplyDriver(BaseDriver, IPowerSupply):
|
||||
"""SCPI driver for programmable power supplies.
|
||||
|
||||
Provides high-level Python API for controlling power supplies via
|
||||
SCPI commands. Implements the IPowerSupply interface.
|
||||
|
||||
Note: This driver assumes a single-channel instrument. The channel
|
||||
parameter is accepted for interface compatibility but currently ignored.
|
||||
|
||||
SCPI Commands Used:
|
||||
VOLT <value> - Set output voltage (V)
|
||||
VOLT? - Query voltage setpoint
|
||||
CURR <value> - Set current limit (A)
|
||||
CURR? - Query current limit
|
||||
OUTP <ON|OFF|1|0> - Enable/disable output
|
||||
OUTP? - Query output state (1=on, 0=off)
|
||||
MEAS:VOLT? - Measure actual output voltage
|
||||
MEAS:CURR? - Measure actual output current
|
||||
|
||||
Example:
|
||||
>>> transport = TCPTransport("localhost", 5002)
|
||||
>>> psu = PowerSupplyDriver(transport)
|
||||
>>> psu.connect()
|
||||
>>> psu.set_voltage(1, 3.3)
|
||||
>>> psu.set_current_limit(1, 0.5)
|
||||
>>> psu.enable_output(1, True)
|
||||
>>> voltage = psu.measure_voltage(1)
|
||||
"""
|
||||
|
||||
def set_voltage(self, channel: int, voltage: float) -> None:
|
||||
"""Set the output voltage setpoint.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
voltage: Target voltage in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
self.write(f"VOLT {voltage:.3f}")
|
||||
|
||||
def get_voltage(self, channel: int) -> float:
|
||||
"""Get the voltage setpoint.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
|
||||
Returns:
|
||||
Current voltage setpoint in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("VOLT?")
|
||||
|
||||
def set_current_limit(self, channel: int, current: float) -> None:
|
||||
"""Set the current limit.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
current: Current limit in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
self.write(f"CURR {current:.3f}")
|
||||
|
||||
def get_current_limit(self, channel: int) -> float:
|
||||
"""Get the current limit.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
|
||||
Returns:
|
||||
Current limit in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("CURR?")
|
||||
|
||||
def measure_voltage(self, channel: int) -> float:
|
||||
"""Measure the actual output voltage.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
|
||||
Returns:
|
||||
Measured voltage in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("MEAS:VOLT?")
|
||||
|
||||
def measure_current(self, channel: int) -> float:
|
||||
"""Measure the actual output current.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
|
||||
Returns:
|
||||
Measured current in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_float("MEAS:CURR?")
|
||||
|
||||
def enable_output(self, channel: int, enable: bool) -> None:
|
||||
"""Enable or disable the output.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
enable: True to enable output, False to disable.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If command fails.
|
||||
"""
|
||||
state = "ON" if enable else "OFF"
|
||||
self.write(f"OUTP {state}")
|
||||
|
||||
def is_output_enabled(self, channel: int) -> bool:
|
||||
"""Check if output is enabled.
|
||||
|
||||
Args:
|
||||
channel: Channel number (currently ignored, single channel assumed).
|
||||
|
||||
Returns:
|
||||
True if output is enabled, False if disabled.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If query fails.
|
||||
"""
|
||||
return self.query_bool("OUTP?")
|
||||
176
src/py_dvt_ate/instruments/factory.py
Normal file
176
src/py_dvt_ate/instruments/factory.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Instrument factory for creating configured instrument sets.
|
||||
|
||||
This module provides a factory pattern for creating sets of instruments
|
||||
based on configuration. It abstracts away the choice between simulated
|
||||
and real hardware, allowing test code to be written once and run against
|
||||
either backend.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from py_dvt_ate.instruments.interfaces import IMultimeter, IPowerSupply, IThermalChamber
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstrumentSet:
|
||||
"""Container for a complete set of instruments.
|
||||
|
||||
Holds all instruments needed for DVT testing. All instruments implement
|
||||
the interface protocols (IThermalChamber, IPowerSupply, IMultimeter),
|
||||
allowing them to be simulated or real hardware.
|
||||
|
||||
Attributes:
|
||||
chamber: Thermal chamber for temperature control.
|
||||
psu: Programmable power supply for DUT power.
|
||||
dmm: Digital multimeter for precision measurements.
|
||||
"""
|
||||
|
||||
chamber: IThermalChamber
|
||||
psu: IPowerSupply
|
||||
dmm: IMultimeter
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstrumentConfig:
|
||||
"""Configuration for instrument connections.
|
||||
|
||||
Defines how to connect to instruments. The backend determines whether
|
||||
to use simulated instruments (TCP connections to virtual instruments)
|
||||
or real hardware (PyVISA connections).
|
||||
|
||||
Attributes:
|
||||
backend: "simulator" for virtual instruments, "pyvisa" for real hardware.
|
||||
|
||||
Simulator Settings:
|
||||
simulator_host: Hostname/IP of simulation server. Default "localhost".
|
||||
chamber_port: TCP port for thermal chamber simulator. Default 5001.
|
||||
psu_port: TCP port for power supply simulator. Default 5002.
|
||||
dmm_port: TCP port for multimeter simulator. Default 5003.
|
||||
|
||||
PyVISA Settings (for real hardware):
|
||||
chamber_visa: VISA resource string for thermal chamber (e.g., "TCPIP::192.168.1.10::INSTR").
|
||||
psu_visa: VISA resource string for power supply.
|
||||
dmm_visa: VISA resource string for multimeter.
|
||||
"""
|
||||
|
||||
backend: Literal["simulator", "pyvisa"]
|
||||
|
||||
# Simulator settings
|
||||
simulator_host: str = "localhost"
|
||||
chamber_port: int = 5001
|
||||
psu_port: int = 5002
|
||||
dmm_port: int = 5003
|
||||
|
||||
# PyVISA settings (for real hardware)
|
||||
chamber_visa: str | None = None
|
||||
psu_visa: str | None = None
|
||||
dmm_visa: str | None = None
|
||||
|
||||
|
||||
class InstrumentFactory:
|
||||
"""Factory for creating instrument sets from configuration.
|
||||
|
||||
This factory encapsulates the creation logic for instrument sets,
|
||||
hiding the complexity of instantiating transports and drivers based
|
||||
on the chosen backend.
|
||||
|
||||
Example:
|
||||
>>> config = InstrumentConfig(backend="simulator")
|
||||
>>> instruments = InstrumentFactory.create(config)
|
||||
>>> instruments.chamber.set_temperature(85.0)
|
||||
>>> instruments.psu.set_voltage(1, 3.3)
|
||||
>>> voltage = instruments.dmm.measure_dc_voltage()
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create(config: InstrumentConfig) -> InstrumentSet:
|
||||
"""Create instrument set based on configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration specifying backend and connection details.
|
||||
|
||||
Returns:
|
||||
InstrumentSet containing all configured instruments.
|
||||
|
||||
Raises:
|
||||
ValueError: If backend is unknown or configuration is invalid.
|
||||
ConnectionError: If unable to connect to instruments.
|
||||
"""
|
||||
if config.backend == "simulator":
|
||||
return InstrumentFactory._create_simulated(config)
|
||||
elif config.backend == "pyvisa":
|
||||
return InstrumentFactory._create_pyvisa(config)
|
||||
else:
|
||||
raise ValueError(f"Unknown backend: {config.backend}")
|
||||
|
||||
@staticmethod
|
||||
def _create_simulated(config: InstrumentConfig) -> InstrumentSet:
|
||||
"""Create simulated instruments connected via TCP.
|
||||
|
||||
Creates TCP transports for each virtual instrument and wraps them
|
||||
in SCPI drivers. The simulation server must be running and listening
|
||||
on the configured ports.
|
||||
|
||||
Args:
|
||||
config: Configuration with simulator_host and port settings.
|
||||
|
||||
Returns:
|
||||
InstrumentSet with simulated instruments.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If unable to connect to simulation server.
|
||||
"""
|
||||
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
|
||||
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
|
||||
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
|
||||
from py_dvt_ate.instruments.transport.tcp import TCPTransport
|
||||
|
||||
# Create transports for each instrument
|
||||
chamber_transport = TCPTransport(config.simulator_host, config.chamber_port)
|
||||
psu_transport = TCPTransport(config.simulator_host, config.psu_port)
|
||||
dmm_transport = TCPTransport(config.simulator_host, config.dmm_port)
|
||||
|
||||
# Wrap transports in drivers
|
||||
return InstrumentSet(
|
||||
chamber=ThermalChamberDriver(chamber_transport),
|
||||
psu=PowerSupplyDriver(psu_transport),
|
||||
dmm=MultimeterDriver(dmm_transport),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_pyvisa(config: InstrumentConfig) -> InstrumentSet:
|
||||
"""Create PyVISA instruments for real hardware.
|
||||
|
||||
Creates VISA transports for each real instrument and wraps them
|
||||
in SCPI drivers. Requires PyVISA to be installed and VISA resource
|
||||
strings to be configured.
|
||||
|
||||
Args:
|
||||
config: Configuration with chamber_visa, psu_visa, dmm_visa settings.
|
||||
|
||||
Returns:
|
||||
InstrumentSet with real hardware instruments.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: PyVISA backend not yet implemented.
|
||||
ValueError: If required VISA resource strings are missing.
|
||||
"""
|
||||
# Future implementation would use pyvisa.ResourceManager
|
||||
# to create VISA transports:
|
||||
#
|
||||
# import pyvisa
|
||||
# from py_dvt_ate.instruments.transport.visa import VISATransport
|
||||
#
|
||||
# rm = pyvisa.ResourceManager()
|
||||
# chamber_transport = VISATransport(rm.open_resource(config.chamber_visa))
|
||||
# psu_transport = VISATransport(rm.open_resource(config.psu_visa))
|
||||
# dmm_transport = VISATransport(rm.open_resource(config.dmm_visa))
|
||||
#
|
||||
# return InstrumentSet(
|
||||
# chamber=ThermalChamberDriver(chamber_transport),
|
||||
# psu=PowerSupplyDriver(psu_transport),
|
||||
# dmm=MultimeterDriver(dmm_transport),
|
||||
# )
|
||||
|
||||
raise NotImplementedError("PyVISA backend not yet implemented")
|
||||
362
src/py_dvt_ate/instruments/interfaces.py
Normal file
362
src/py_dvt_ate/instruments/interfaces.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Instrument interface protocols.
|
||||
|
||||
This module defines the Hardware Abstraction Layer (HAL) interfaces for all
|
||||
laboratory instruments used in DVT testing. These protocols allow test code
|
||||
to be written against abstract interfaces rather than concrete implementations,
|
||||
enabling seamless switching between simulated and real hardware.
|
||||
|
||||
The interfaces use ABC (Abstract Base Classes) for maximum type safety and
|
||||
explicit interface implementation. All drivers must inherit from these base
|
||||
classes and implement all abstract methods.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class IThermalChamber(ABC):
|
||||
"""Hardware abstraction for thermal chambers.
|
||||
|
||||
Defines the interface for controlling environmental temperature during
|
||||
thermal characterisation tests. Implementations may be virtual instruments
|
||||
(simulators) or real hardware drivers.
|
||||
|
||||
Temperature units are always degrees Celsius.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def set_temperature(self, setpoint: float) -> None:
|
||||
"""Set the chamber temperature setpoint.
|
||||
|
||||
Args:
|
||||
setpoint: Target temperature in degrees Celsius.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If command fails or instrument reports error.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_temperature(self) -> float:
|
||||
"""Get the actual chamber temperature.
|
||||
|
||||
Returns:
|
||||
Current chamber air temperature in degrees Celsius.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_setpoint(self) -> float:
|
||||
"""Get the current temperature setpoint.
|
||||
|
||||
Returns:
|
||||
Current target temperature in degrees Celsius.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_stable(self) -> bool:
|
||||
"""Check if chamber temperature is stable.
|
||||
|
||||
Temperature is considered stable when it has settled within
|
||||
the instrument's configured stability threshold of the setpoint
|
||||
for a minimum dwell time.
|
||||
|
||||
Returns:
|
||||
True if temperature is stable, False if still settling.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def wait_until_stable(
|
||||
self, timeout: float = 300.0, poll_interval: float = 1.0
|
||||
) -> bool:
|
||||
"""Wait until chamber temperature stabilises.
|
||||
|
||||
Polls the stability status at regular intervals until stable
|
||||
or timeout is reached. This is a blocking call.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds. Default 300s (5 minutes).
|
||||
poll_interval: Time between stability checks in seconds. Default 1s.
|
||||
|
||||
Returns:
|
||||
True if temperature stabilised within timeout, False if timed out.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If communication fails.
|
||||
ValueError: If timeout or poll_interval are invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_ramp_rate(self, rate: float) -> None:
|
||||
"""Set the temperature ramp rate.
|
||||
|
||||
Controls how quickly the chamber changes temperature when moving
|
||||
to a new setpoint. Slower ramp rates reduce thermal shock to DUT.
|
||||
|
||||
Args:
|
||||
rate: Ramp rate in degrees Celsius per minute.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If command fails or instrument reports error.
|
||||
ValueError: If rate is negative or exceeds instrument limits.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class IPowerSupply(ABC):
|
||||
"""Hardware abstraction for programmable power supplies.
|
||||
|
||||
Defines the interface for controlling DC power supplies during electrical
|
||||
characterisation tests. Implementations may be virtual instruments
|
||||
(simulators) or real hardware drivers.
|
||||
|
||||
Voltage units are always volts (V).
|
||||
Current units are always amps (A).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def set_voltage(self, channel: int, voltage: float) -> None:
|
||||
"""Set the output voltage setpoint.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
voltage: Target voltage in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If command fails or instrument reports error.
|
||||
ValueError: If channel is invalid or voltage out of range.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_voltage(self, channel: int) -> float:
|
||||
"""Get the voltage setpoint.
|
||||
|
||||
Returns the programmed voltage, not the measured output voltage.
|
||||
Use measure_voltage() to get the actual output voltage.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
|
||||
Returns:
|
||||
Current voltage setpoint in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If channel is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_current_limit(self, channel: int, current: float) -> None:
|
||||
"""Set the current limit.
|
||||
|
||||
The supply will operate in constant voltage mode until output current
|
||||
reaches this limit, then transition to constant current mode.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
current: Current limit in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If command fails or instrument reports error.
|
||||
ValueError: If channel is invalid or current out of range.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_current_limit(self, channel: int) -> float:
|
||||
"""Get the current limit.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
|
||||
Returns:
|
||||
Current limit in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If channel is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_voltage(self, channel: int) -> float:
|
||||
"""Measure the actual output voltage.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
|
||||
Returns:
|
||||
Measured output voltage in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If channel is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_current(self, channel: int) -> float:
|
||||
"""Measure the actual output current.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
|
||||
Returns:
|
||||
Measured output current in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If channel is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def enable_output(self, channel: int, enable: bool) -> None:
|
||||
"""Enable or disable the output.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
enable: True to enable output, False to disable.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If command fails or instrument reports error.
|
||||
ValueError: If channel is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_output_enabled(self, channel: int) -> bool:
|
||||
"""Check if output is enabled.
|
||||
|
||||
Args:
|
||||
channel: Output channel number (1-based indexing).
|
||||
|
||||
Returns:
|
||||
True if output is enabled, False if disabled.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If channel is invalid.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class IMultimeter(ABC):
|
||||
"""Hardware abstraction for digital multimeters.
|
||||
|
||||
Defines the interface for making precision measurements with DMMs during
|
||||
electrical characterisation tests. Implementations may be virtual instruments
|
||||
(simulators) or real hardware drivers.
|
||||
|
||||
Voltage units are always volts (V).
|
||||
Current units are always amps (A).
|
||||
Resistance units are always ohms (Ω).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def measure_dc_voltage(self, range: str = "AUTO") -> float:
|
||||
"""Measure DC voltage.
|
||||
|
||||
Configures the meter for DC voltage and takes a measurement.
|
||||
|
||||
Args:
|
||||
range: Measurement range. Default "AUTO" for auto-ranging.
|
||||
Specific ranges depend on instrument capabilities.
|
||||
|
||||
Returns:
|
||||
Measured voltage in volts.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If range is invalid for this instrument.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_dc_current(self, range: str = "AUTO") -> float:
|
||||
"""Measure DC current.
|
||||
|
||||
Configures the meter for DC current and takes a measurement.
|
||||
|
||||
Args:
|
||||
range: Measurement range. Default "AUTO" for auto-ranging.
|
||||
Specific ranges depend on instrument capabilities.
|
||||
|
||||
Returns:
|
||||
Measured current in amps.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If range is invalid for this instrument.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def measure_resistance(self, range: str = "AUTO") -> float:
|
||||
"""Measure resistance.
|
||||
|
||||
Configures the meter for resistance and takes a measurement.
|
||||
|
||||
Args:
|
||||
range: Measurement range. Default "AUTO" for auto-ranging.
|
||||
Specific ranges depend on instrument capabilities.
|
||||
|
||||
Returns:
|
||||
Measured resistance in ohms.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If query fails or instrument reports error.
|
||||
ValueError: If range is invalid for this instrument.
|
||||
NotImplementedError: If instrument does not support resistance.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_integration_time(self, nplc: float) -> None:
|
||||
"""Set the integration time.
|
||||
|
||||
Integration time affects measurement accuracy and speed. Higher
|
||||
values (more power line cycles) provide better noise rejection
|
||||
but take longer to measure.
|
||||
|
||||
Args:
|
||||
nplc: Integration time in number of power line cycles (NPLC).
|
||||
Typical values: 0.02, 0.2, 1, 10, 100.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected to instrument.
|
||||
IOError: If command fails or instrument reports error.
|
||||
ValueError: If nplc is invalid for this instrument.
|
||||
NotImplementedError: If instrument does not support integration time.
|
||||
"""
|
||||
pass
|
||||
87
src/py_dvt_ate/instruments/scpi.py
Normal file
87
src/py_dvt_ate/instruments/scpi.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""SCPI command parsing.
|
||||
|
||||
This module provides SCPI (Standard Commands for Programmable Instruments)
|
||||
command parsing for instrument communication. It handles IEEE 488.2 common
|
||||
commands (*IDN?, *RST, etc.) and instrument-specific commands.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SCPICommand:
|
||||
"""Parsed SCPI command.
|
||||
|
||||
Attributes:
|
||||
header: The command header (e.g., "TEMP:SETPOINT" or "*IDN").
|
||||
arguments: List of command arguments (e.g., ["85.0"]).
|
||||
is_query: True if the command ends with '?' (query command).
|
||||
"""
|
||||
|
||||
header: str
|
||||
arguments: list[str]
|
||||
is_query: bool
|
||||
|
||||
@property
|
||||
def keyword(self) -> str:
|
||||
"""Return the command keyword without '?'.
|
||||
|
||||
For query commands like "TEMP:SETPOINT?", returns "TEMP:SETPOINT".
|
||||
For regular commands like "VOLT", returns "VOLT".
|
||||
"""
|
||||
return self.header.rstrip("?")
|
||||
|
||||
|
||||
class SCPIParser:
|
||||
"""Parse SCPI command strings.
|
||||
|
||||
Handles both IEEE 488.2 common commands (e.g., *IDN?, *RST) and
|
||||
instrument-specific commands (e.g., VOLT 3.3, TEMP:SETPOINT?).
|
||||
|
||||
Examples:
|
||||
>>> parser = SCPIParser()
|
||||
>>> cmd = parser.parse("*IDN?")
|
||||
>>> cmd.header, cmd.is_query
|
||||
('*IDN?', True)
|
||||
>>> cmd = parser.parse("VOLT 3.3")
|
||||
>>> cmd.header, cmd.arguments
|
||||
('VOLT', ['3.3'])
|
||||
"""
|
||||
|
||||
def parse(self, command_string: str) -> SCPICommand:
|
||||
"""Parse a SCPI command string.
|
||||
|
||||
Args:
|
||||
command_string: The raw SCPI command string to parse.
|
||||
|
||||
Returns:
|
||||
SCPICommand with parsed header, arguments, and query flag.
|
||||
|
||||
Examples:
|
||||
"*IDN?" -> SCPICommand("*IDN?", [], True)
|
||||
"VOLT 3.3" -> SCPICommand("VOLT", ["3.3"], False)
|
||||
"TEMP:SETPOINT?" -> SCPICommand("TEMP:SETPOINT?", [], True)
|
||||
"CONF:VOLT:DC 10,0.001" -> SCPICommand("CONF:VOLT:DC", ["10", "0.001"], False)
|
||||
"""
|
||||
command_string = command_string.strip()
|
||||
if not command_string:
|
||||
return SCPICommand(header="", arguments=[], is_query=False)
|
||||
|
||||
# Split into header and arguments on first whitespace
|
||||
parts = command_string.split(None, 1)
|
||||
header = parts[0]
|
||||
arguments: list[str] = []
|
||||
|
||||
if len(parts) > 1:
|
||||
# Parse comma-separated arguments
|
||||
arg_string = parts[1]
|
||||
arguments = [arg.strip() for arg in arg_string.split(",")]
|
||||
|
||||
# Query is determined by whether the header ends with '?'
|
||||
is_query = header.endswith("?")
|
||||
|
||||
return SCPICommand(
|
||||
header=header,
|
||||
arguments=arguments,
|
||||
is_query=is_query,
|
||||
)
|
||||
@@ -1,6 +1,13 @@
|
||||
"""Transport layer for instrument communication.
|
||||
|
||||
Provides connection abstractions for different backends:
|
||||
- TCP server for hosting SCPI instruments
|
||||
- TCP sockets (for simulation server)
|
||||
- PyVISA (for real instruments)
|
||||
"""
|
||||
|
||||
from py_dvt_ate.instruments.transport.base import Transport
|
||||
from py_dvt_ate.instruments.transport.server import InstrumentServer, SCPIDevice
|
||||
from py_dvt_ate.instruments.transport.tcp import TCPTransport
|
||||
|
||||
__all__ = ["Transport", "TCPTransport", "InstrumentServer", "SCPIDevice"]
|
||||
|
||||
93
src/py_dvt_ate/instruments/transport/base.py
Normal file
93
src/py_dvt_ate/instruments/transport/base.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Base transport interface for instrument communication."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class Transport(ABC):
|
||||
"""Abstract transport interface for instrument communication.
|
||||
|
||||
This abstract base class defines the interface that all transport
|
||||
implementations (TCP, VISA, etc.) must implement. It provides basic
|
||||
connection management and communication primitives for SCPI-based
|
||||
instruments.
|
||||
|
||||
Implementations must inherit from this class and implement all abstract
|
||||
methods.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> None:
|
||||
"""Establish connection to instrument.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If connection fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Close connection to instrument.
|
||||
|
||||
Should be idempotent - safe to call multiple times.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, command: str) -> None:
|
||||
"""Send command to instrument.
|
||||
|
||||
Args:
|
||||
command: SCPI command string to send (without terminator).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If write fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def read(self, timeout: float | None = None) -> str:
|
||||
"""Read response from instrument.
|
||||
|
||||
Args:
|
||||
timeout: Read timeout in seconds. None uses default.
|
||||
|
||||
Returns:
|
||||
Response string from instrument (without terminator).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If read fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def query(self, command: str, timeout: float | None = None) -> str:
|
||||
"""Send command and read response.
|
||||
|
||||
Convenience method combining write() and read().
|
||||
|
||||
Args:
|
||||
command: SCPI command string to send.
|
||||
timeout: Read timeout in seconds. None uses default.
|
||||
|
||||
Returns:
|
||||
Response string from instrument.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connection is active.
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise.
|
||||
"""
|
||||
pass
|
||||
240
src/py_dvt_ate/instruments/transport/server.py
Normal file
240
src/py_dvt_ate/instruments/transport/server.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Async TCP server for exposing instruments over network.
|
||||
|
||||
This module provides the InstrumentServer class that hosts SCPI
|
||||
instruments over TCP, allowing client applications to communicate using
|
||||
standard SCPI commands over a network connection.
|
||||
|
||||
This is a general-purpose server that works with any object implementing
|
||||
the SCPIDevice protocol (having a process(command) -> str method).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
__all__ = ["InstrumentServer", "SCPIDevice"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SCPIDevice(Protocol):
|
||||
"""Protocol for SCPI-compatible devices.
|
||||
|
||||
Any object with a process method matching this signature can be
|
||||
served by InstrumentServer.
|
||||
"""
|
||||
|
||||
def process(self, command: str) -> str:
|
||||
"""Process a SCPI command and return the response.
|
||||
|
||||
Args:
|
||||
command: SCPI command string to process.
|
||||
|
||||
Returns:
|
||||
Response string (may be empty for commands with no response).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InstrumentServer:
|
||||
"""Async TCP server hosting SCPI instruments.
|
||||
|
||||
Each instrument is assigned a port. Clients connect via TCP and send
|
||||
SCPI commands as newline-terminated strings. Responses are also
|
||||
newline-terminated.
|
||||
|
||||
This server can host any device implementing the SCPIDevice protocol,
|
||||
including both virtual instruments (simulators) and adapters for
|
||||
real hardware.
|
||||
|
||||
Attributes:
|
||||
host: Host address to bind to.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1") -> None:
|
||||
"""Initialise the instrument server.
|
||||
|
||||
Args:
|
||||
host: Host address to bind to. Defaults to localhost.
|
||||
"""
|
||||
self._host = host
|
||||
self._instruments: dict[int, SCPIDevice] = {}
|
||||
self._servers: list[asyncio.Server] = []
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Get the host address."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if server is currently running."""
|
||||
return self._running
|
||||
|
||||
def register_instrument(self, port: int, instrument: SCPIDevice) -> None:
|
||||
"""Register an instrument to be served on a specific port.
|
||||
|
||||
Args:
|
||||
port: TCP port number to serve the instrument on.
|
||||
instrument: SCPI device to serve (any object with process method).
|
||||
|
||||
Raises:
|
||||
ValueError: If port is already registered.
|
||||
RuntimeError: If server is already running.
|
||||
"""
|
||||
if self._running:
|
||||
raise RuntimeError("Cannot register instruments while server is running")
|
||||
|
||||
if port in self._instruments:
|
||||
raise ValueError(f"Port {port} is already registered")
|
||||
|
||||
self._instruments[port] = instrument
|
||||
logger.info(
|
||||
"Registered %s on port %d",
|
||||
instrument.__class__.__name__,
|
||||
port,
|
||||
)
|
||||
|
||||
def get_instrument(self, port: int) -> SCPIDevice | None:
|
||||
"""Get the instrument registered on a port.
|
||||
|
||||
Args:
|
||||
port: Port number to look up.
|
||||
|
||||
Returns:
|
||||
Registered instrument, or None if port not registered.
|
||||
"""
|
||||
return self._instruments.get(port)
|
||||
|
||||
@property
|
||||
def registered_ports(self) -> list[int]:
|
||||
"""Get list of registered port numbers."""
|
||||
return list(self._instruments.keys())
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the server and begin listening on all registered ports.
|
||||
|
||||
Creates a TCP server for each registered instrument port.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If server is already running or no instruments registered.
|
||||
"""
|
||||
if self._running:
|
||||
raise RuntimeError("Server is already running")
|
||||
|
||||
if not self._instruments:
|
||||
raise RuntimeError("No instruments registered")
|
||||
|
||||
self._running = True
|
||||
|
||||
for port, instrument in self._instruments.items():
|
||||
handler = partial(self._handle_client, instrument=instrument, port=port)
|
||||
server = await asyncio.start_server(
|
||||
handler,
|
||||
self._host,
|
||||
port,
|
||||
reuse_address=True,
|
||||
)
|
||||
self._servers.append(server)
|
||||
logger.info(
|
||||
"Started server for %s on %s:%d",
|
||||
instrument.__class__.__name__,
|
||||
self._host,
|
||||
port,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the server and close all connections."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
for server in self._servers:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
self._servers.clear()
|
||||
self._running = False
|
||||
logger.info("Server stopped")
|
||||
|
||||
async def serve_forever(self) -> None:
|
||||
"""Start the server and run until cancelled.
|
||||
|
||||
This is a convenience method that starts the server and blocks
|
||||
until the server is stopped or cancelled.
|
||||
"""
|
||||
await self.start()
|
||||
try:
|
||||
# Keep running until cancelled
|
||||
await asyncio.gather(
|
||||
*[server.serve_forever() for server in self._servers]
|
||||
)
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def _handle_client(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
instrument: SCPIDevice,
|
||||
port: int,
|
||||
) -> None:
|
||||
"""Handle a client connection.
|
||||
|
||||
Reads SCPI commands (newline-terminated), processes them through
|
||||
the instrument, and sends back responses (newline-terminated).
|
||||
|
||||
Args:
|
||||
reader: Stream reader for incoming data.
|
||||
writer: Stream writer for outgoing data.
|
||||
instrument: The instrument to process commands.
|
||||
port: Port number for logging.
|
||||
"""
|
||||
addr = writer.get_extra_info("peername")
|
||||
logger.info("Client connected to port %d from %s", port, addr)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Read until newline (SCPI line terminator)
|
||||
data = await reader.readline()
|
||||
|
||||
if not data:
|
||||
# Client disconnected
|
||||
break
|
||||
|
||||
# Decode and strip whitespace
|
||||
command = data.decode("utf-8").strip()
|
||||
|
||||
if not command:
|
||||
continue
|
||||
|
||||
logger.debug("Port %d received: %s", port, command)
|
||||
|
||||
# Process command through instrument
|
||||
response = instrument.process(command)
|
||||
|
||||
# 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:
|
||||
writer.write(f"{response}\n".encode())
|
||||
await writer.drain()
|
||||
logger.debug("Port %d sent: %s", port, response)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Client handler cancelled for port %d", port)
|
||||
except ConnectionResetError:
|
||||
logger.debug("Client connection reset on port %d", port)
|
||||
except Exception as e:
|
||||
logger.error("Error handling client on port %d: %s", port, e)
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Client disconnected from port %d", port)
|
||||
195
src/py_dvt_ate/instruments/transport/tcp.py
Normal file
195
src/py_dvt_ate/instruments/transport/tcp.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""TCP socket transport for instrument communication."""
|
||||
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
from py_dvt_ate.instruments.transport.base import Transport
|
||||
|
||||
|
||||
class TCPTransport(Transport):
|
||||
"""TCP socket transport implementation.
|
||||
|
||||
Implements the Transport interface for communicating with SCPI
|
||||
instruments over TCP/IP using newline-terminated messages.
|
||||
|
||||
Attributes:
|
||||
host: Hostname or IP address of the instrument.
|
||||
port: TCP port number.
|
||||
timeout: Default socket timeout in seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float = 5.0,
|
||||
encoding: str = "utf-8",
|
||||
) -> None:
|
||||
"""Initialise TCP transport.
|
||||
|
||||
Args:
|
||||
host: Hostname or IP address.
|
||||
port: TCP port number.
|
||||
timeout: Default socket timeout in seconds.
|
||||
encoding: Text encoding for commands and responses.
|
||||
"""
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._encoding = encoding
|
||||
self._socket: socket.socket | None = None
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Get the host address."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
"""Get the port number."""
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connection is active.
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise.
|
||||
"""
|
||||
return self._socket is not None
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Establish connection to instrument.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If connection fails or already connected.
|
||||
"""
|
||||
if self.is_connected:
|
||||
raise ConnectionError("Already connected")
|
||||
|
||||
try:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.settimeout(self._timeout)
|
||||
self._socket.connect((self._host, self._port))
|
||||
except OSError as err:
|
||||
self._socket = None
|
||||
raise ConnectionError(
|
||||
f"Failed to connect to {self._host}:{self._port}: {err}"
|
||||
) from err
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close connection to instrument.
|
||||
|
||||
Safe to call multiple times (idempotent).
|
||||
"""
|
||||
if self._socket is not None:
|
||||
try:
|
||||
self._socket.close()
|
||||
except OSError:
|
||||
pass # Ignore errors during close
|
||||
finally:
|
||||
self._socket = None
|
||||
|
||||
def write(self, command: str) -> None:
|
||||
"""Send command to instrument.
|
||||
|
||||
Commands are sent with newline terminator appended.
|
||||
|
||||
Args:
|
||||
command: SCPI command string to send (without terminator).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
IOError: If write fails.
|
||||
"""
|
||||
if not self.is_connected or self._socket is None:
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
try:
|
||||
message = f"{command}\n".encode(self._encoding)
|
||||
self._socket.sendall(message)
|
||||
except OSError as err:
|
||||
raise OSError(f"Write failed: {err}") from err
|
||||
|
||||
def read(self, timeout: float | None = None) -> str:
|
||||
"""Read response from instrument.
|
||||
|
||||
Reads until newline terminator is received.
|
||||
|
||||
Args:
|
||||
timeout: Read timeout in seconds. None uses default.
|
||||
|
||||
Returns:
|
||||
Response string from instrument (without terminator).
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If read fails.
|
||||
"""
|
||||
if not self.is_connected or self._socket is None:
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
# Set timeout if specified
|
||||
old_timeout = self._socket.gettimeout()
|
||||
if timeout is not None:
|
||||
self._socket.settimeout(timeout)
|
||||
|
||||
try:
|
||||
# Read line by line (newline-terminated protocol)
|
||||
response_bytes = b""
|
||||
while True:
|
||||
chunk = self._socket.recv(1)
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed by remote host")
|
||||
response_bytes += chunk
|
||||
if chunk == b"\n":
|
||||
break
|
||||
|
||||
# Decode and strip whitespace
|
||||
return response_bytes.decode(self._encoding).strip()
|
||||
|
||||
except ConnectionError:
|
||||
raise # Re-raise ConnectionError as-is
|
||||
except TimeoutError as err:
|
||||
raise TimeoutError("Read timeout") from err
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
raise OSError(f"Read failed: {err}") from err
|
||||
finally:
|
||||
# Restore original timeout
|
||||
if timeout is not None:
|
||||
self._socket.settimeout(old_timeout)
|
||||
|
||||
def query(self, command: str, timeout: float | None = None) -> str:
|
||||
"""Send command and read response.
|
||||
|
||||
Convenience method combining write() and read().
|
||||
|
||||
Args:
|
||||
command: SCPI command string to send.
|
||||
timeout: Read timeout in seconds. None uses default.
|
||||
|
||||
Returns:
|
||||
Response string from instrument.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If not connected.
|
||||
TimeoutError: If read times out.
|
||||
IOError: If communication fails.
|
||||
"""
|
||||
self.write(command)
|
||||
return self.read(timeout)
|
||||
|
||||
def __enter__(self) -> "TCPTransport":
|
||||
"""Context manager entry."""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
"""Context manager exit."""
|
||||
self.disconnect()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation."""
|
||||
status = "connected" if self.is_connected else "disconnected"
|
||||
return f"TCPTransport({self._host}:{self._port}, {status})"
|
||||
@@ -1,5 +1,58 @@
|
||||
"""Report generation.
|
||||
"""Report generation module for py_dvt_ate.
|
||||
|
||||
Generates test reports from stored data in various formats
|
||||
including PDF and HTML.
|
||||
This module provides automated PDF report generation from test results.
|
||||
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 %}
|
||||
@@ -2,4 +2,10 @@
|
||||
|
||||
Provides virtual instruments backed by a coupled thermal-electrical
|
||||
physics engine. Used for development and testing without real hardware.
|
||||
|
||||
Note: InstrumentServer has moved to py_dvt_ate.instruments.transport
|
||||
"""
|
||||
|
||||
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
||||
|
||||
__all__ = ["ServerConfig", "SimulationServer"]
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Base protocol for Device Under Test (DUT) models.
|
||||
"""Base interface for Device Under Test (DUT) models.
|
||||
|
||||
Defines the interface that all DUT models must implement to integrate
|
||||
with the physics engine.
|
||||
"""
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DUTModel(Protocol):
|
||||
"""Protocol for DUT electrical/thermal models.
|
||||
class DUTModel(ABC):
|
||||
"""Abstract base class for DUT electrical/thermal models.
|
||||
|
||||
DUT models encapsulate the temperature-dependent electrical behaviour
|
||||
of a device, enabling realistic simulation of thermal-electrical coupling.
|
||||
@@ -18,8 +17,12 @@ class DUTModel(Protocol):
|
||||
All voltage parameters are in volts.
|
||||
All current parameters are in amps.
|
||||
All power parameters are in watts.
|
||||
|
||||
Implementations must inherit from this class and implement all abstract
|
||||
methods.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def calculate_output_voltage(self, junction_temperature: float) -> float:
|
||||
"""Calculate the output voltage at the given junction temperature.
|
||||
|
||||
@@ -29,8 +32,9 @@ class DUTModel(Protocol):
|
||||
Returns:
|
||||
Output voltage in volts.
|
||||
"""
|
||||
...
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def calculate_quiescent_current(self, junction_temperature: float) -> float:
|
||||
"""Calculate the quiescent current at the given junction temperature.
|
||||
|
||||
@@ -40,8 +44,9 @@ class DUTModel(Protocol):
|
||||
Returns:
|
||||
Quiescent current in amps.
|
||||
"""
|
||||
...
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def calculate_power_dissipation(
|
||||
self,
|
||||
input_voltage: float,
|
||||
@@ -58,4 +63,4 @@ class DUTModel(Protocol):
|
||||
Returns:
|
||||
Power dissipation in watts.
|
||||
"""
|
||||
...
|
||||
pass
|
||||
|
||||
@@ -7,6 +7,8 @@ and power dissipation calculations.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from py_dvt_ate.simulation.physics.models.base import DUTModel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LDOParameters:
|
||||
@@ -35,7 +37,7 @@ class LDOParameters:
|
||||
REFERENCE_TEMPERATURE_C = 25.0
|
||||
|
||||
|
||||
class LDOModel:
|
||||
class LDOModel(DUTModel):
|
||||
"""Temperature-dependent LDO voltage regulator model.
|
||||
|
||||
Models the electrical behaviour of a linear voltage regulator with:
|
||||
@@ -44,7 +46,7 @@ class LDOModel:
|
||||
- Dropout voltage that increases with temperature
|
||||
- Power dissipation from (Vin - Vout) × Iload + Vin × Iq
|
||||
|
||||
This class implements the DUTModel protocol.
|
||||
This class implements the DUTModel interface.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -194,7 +196,7 @@ class LDOModel:
|
||||
# Temperature ratio (reference is approximately 300K ≈ 27°C)
|
||||
temp_ratio = t_kelvin / 300.0
|
||||
|
||||
return self._params.dropout_voltage * (temp_ratio**1.5)
|
||||
return float(self._params.dropout_voltage * (temp_ratio**1.5))
|
||||
|
||||
def is_in_dropout(self, junction_temperature: float) -> bool:
|
||||
"""Check if the LDO is in dropout at current operating point.
|
||||
|
||||
266
src/py_dvt_ate/simulation/server.py
Normal file
266
src/py_dvt_ate/simulation/server.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Simulation server entry point.
|
||||
|
||||
This module provides the main entry point for running the simulation server
|
||||
with all virtual instruments wired to a shared physics engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
from dataclasses import dataclass
|
||||
|
||||
from py_dvt_ate.instruments.transport import InstrumentServer
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
from py_dvt_ate.simulation.virtual.chamber import ThermalChamberSim
|
||||
from py_dvt_ate.simulation.virtual.multimeter import MultimeterSim
|
||||
from py_dvt_ate.simulation.virtual.power_supply import PowerSupplySim
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
"""Configuration for the simulation server.
|
||||
|
||||
Attributes:
|
||||
host: Host address to bind to.
|
||||
chamber_port: Port for thermal chamber instrument.
|
||||
psu_port: Port for power supply instrument.
|
||||
dmm_port: Port for multimeter instrument.
|
||||
physics_rate_hz: Physics engine update rate in Hz.
|
||||
"""
|
||||
|
||||
host: str = "127.0.0.1"
|
||||
chamber_port: int = 5000
|
||||
psu_port: int = 5001
|
||||
dmm_port: int = 5002
|
||||
physics_rate_hz: float = 100.0
|
||||
|
||||
|
||||
class SimulationServer:
|
||||
"""Complete simulation server with physics engine and instruments.
|
||||
|
||||
Creates a physics engine and wires it to all virtual instruments,
|
||||
then exposes them over TCP for client access.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ServerConfig | None = None) -> None:
|
||||
"""Initialise the simulation server.
|
||||
|
||||
Args:
|
||||
config: Server configuration. Uses defaults if not provided.
|
||||
"""
|
||||
self._config = config or ServerConfig()
|
||||
self._physics_engine: PhysicsEngine | None = None
|
||||
self._instrument_server: InstrumentServer | None = None
|
||||
self._physics_task: asyncio.Task[None] | None = None
|
||||
self._running = False
|
||||
self._paused = False # Pause physics simulation
|
||||
self._time_scale = 1.0 # Simulation time multiplier
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if server is currently 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
|
||||
def physics_engine(self) -> PhysicsEngine | None:
|
||||
"""Get the physics engine instance."""
|
||||
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:
|
||||
"""Create and wire up all components."""
|
||||
# Create physics engine
|
||||
self._physics_engine = PhysicsEngine(
|
||||
update_rate_hz=self._config.physics_rate_hz
|
||||
)
|
||||
|
||||
# Create instruments connected to physics engine
|
||||
chamber = ThermalChamberSim(self._physics_engine)
|
||||
psu = PowerSupplySim(self._physics_engine)
|
||||
dmm = MultimeterSim(self._physics_engine)
|
||||
|
||||
# Create TCP server and register instruments
|
||||
self._instrument_server = InstrumentServer(host=self._config.host)
|
||||
self._instrument_server.register_instrument(self._config.chamber_port, chamber)
|
||||
self._instrument_server.register_instrument(self._config.psu_port, psu)
|
||||
self._instrument_server.register_instrument(self._config.dmm_port, dmm)
|
||||
|
||||
logger.info(
|
||||
"Simulation server configured: chamber=%d, psu=%d, dmm=%d",
|
||||
self._config.chamber_port,
|
||||
self._config.psu_port,
|
||||
self._config.dmm_port,
|
||||
)
|
||||
|
||||
async def _run_physics(self) -> None:
|
||||
"""Run the physics engine simulation loop."""
|
||||
if self._physics_engine is None:
|
||||
return
|
||||
|
||||
dt = self._physics_engine.dt
|
||||
|
||||
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()
|
||||
# Sleep for the physics timestep (wall clock time)
|
||||
await asyncio.sleep(dt)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the simulation server.
|
||||
|
||||
Sets up all components and starts the TCP server and physics engine.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If server is already running.
|
||||
"""
|
||||
if self._running:
|
||||
raise RuntimeError("Server is already running")
|
||||
|
||||
self._setup()
|
||||
self._running = True
|
||||
|
||||
# Start TCP server
|
||||
if self._instrument_server is not None:
|
||||
await self._instrument_server.start()
|
||||
|
||||
# Start physics engine loop
|
||||
self._physics_task = asyncio.create_task(self._run_physics())
|
||||
|
||||
logger.info("Simulation server started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the simulation server."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
# Cancel physics loop
|
||||
if self._physics_task is not None:
|
||||
self._physics_task.cancel()
|
||||
try:
|
||||
await self._physics_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._physics_task = None
|
||||
|
||||
# Stop TCP server
|
||||
if self._instrument_server is not None:
|
||||
await self._instrument_server.stop()
|
||||
self._instrument_server = None
|
||||
|
||||
self._physics_engine = None
|
||||
logger.info("Simulation server stopped")
|
||||
|
||||
async def serve_forever(self) -> None:
|
||||
"""Start the server and run until cancelled."""
|
||||
await self.start()
|
||||
try:
|
||||
# Wait for the physics task (which runs until cancelled)
|
||||
if self._physics_task is not None:
|
||||
await self._physics_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
|
||||
async def run_server(config: ServerConfig | None = None) -> None:
|
||||
"""Run the simulation server with signal handling.
|
||||
|
||||
This is the main entry point for running the server. It sets up
|
||||
signal handlers for graceful shutdown.
|
||||
|
||||
Args:
|
||||
config: Server configuration. Uses defaults if not provided.
|
||||
"""
|
||||
server = SimulationServer(config)
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
def signal_handler() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
stop_event.set()
|
||||
|
||||
# Register signal handlers (Unix-style, may not work on all Windows)
|
||||
try:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, signal_handler)
|
||||
except NotImplementedError:
|
||||
# Windows doesn't support add_signal_handler
|
||||
pass
|
||||
|
||||
try:
|
||||
await server.start()
|
||||
logger.info("Simulation server running. Press Ctrl+C to stop.")
|
||||
|
||||
# Wait for stop signal
|
||||
await stop_event.wait()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
def main(
|
||||
host: str = "127.0.0.1",
|
||||
chamber_port: int = 5000,
|
||||
psu_port: int = 5001,
|
||||
dmm_port: int = 5002,
|
||||
physics_rate: float = 100.0,
|
||||
) -> None:
|
||||
"""Run the simulation server from command line.
|
||||
|
||||
Args:
|
||||
host: Host address to bind to.
|
||||
chamber_port: Port for thermal chamber.
|
||||
psu_port: Port for power supply.
|
||||
dmm_port: Port for multimeter.
|
||||
physics_rate: Physics engine update rate in Hz.
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
config = ServerConfig(
|
||||
host=host,
|
||||
chamber_port=chamber_port,
|
||||
psu_port=psu_port,
|
||||
dmm_port=dmm_port,
|
||||
physics_rate_hz=physics_rate,
|
||||
)
|
||||
|
||||
asyncio.run(run_server(config))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
src/py_dvt_ate/simulation/virtual/base.py
Normal file
156
src/py_dvt_ate/simulation/virtual/base.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Base class for virtual instrument simulators.
|
||||
|
||||
This module provides the foundation for implementing SCPI-based virtual
|
||||
instruments that can be exposed over TCP for hardware abstraction testing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from py_dvt_ate.instruments.scpi import SCPICommand, SCPIParser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
|
||||
|
||||
# Type alias for command handlers
|
||||
CommandHandler = Callable[[SCPICommand], str]
|
||||
|
||||
|
||||
class BaseInstrument(ABC):
|
||||
"""Abstract base class for virtual SCPI instruments.
|
||||
|
||||
Provides common functionality for SCPI command parsing and dispatch.
|
||||
Subclasses should register command handlers using the register_command
|
||||
method or by overriding _setup_commands.
|
||||
|
||||
Attributes:
|
||||
manufacturer: Instrument manufacturer name for *IDN? response.
|
||||
model: Instrument model name for *IDN? response.
|
||||
serial_number: Instrument serial number for *IDN? response.
|
||||
firmware_version: Firmware version for *IDN? response.
|
||||
"""
|
||||
|
||||
manufacturer: str = "PyDVTATE"
|
||||
model: str = "Virtual Instrument"
|
||||
serial_number: str = "SIM001"
|
||||
firmware_version: str = "1.0.0"
|
||||
|
||||
def __init__(self, physics_engine: PhysicsEngine | None = None) -> None:
|
||||
"""Initialise the base instrument.
|
||||
|
||||
Args:
|
||||
physics_engine: Reference to physics engine for simulation state.
|
||||
May be None for standalone operation.
|
||||
"""
|
||||
self._physics_engine = physics_engine
|
||||
self._parser = SCPIParser()
|
||||
self._handlers: dict[str, CommandHandler] = {}
|
||||
self._setup_common_commands()
|
||||
self._setup_commands()
|
||||
|
||||
def _setup_common_commands(self) -> None:
|
||||
"""Register IEEE 488.2 common commands."""
|
||||
self.register_command("*IDN", self._handle_idn)
|
||||
self.register_command("*RST", self._handle_rst)
|
||||
self.register_command("*CLS", self._handle_cls)
|
||||
self.register_command("*OPC", self._handle_opc)
|
||||
|
||||
@abstractmethod
|
||||
def _setup_commands(self) -> None:
|
||||
"""Register instrument-specific command handlers.
|
||||
|
||||
Subclasses must implement this method to register their
|
||||
SCPI command handlers using register_command().
|
||||
"""
|
||||
|
||||
def register_command(self, keyword: str, handler: CommandHandler) -> None:
|
||||
"""Register a handler for a SCPI command keyword.
|
||||
|
||||
Args:
|
||||
keyword: The command keyword (e.g., "TEMP:SETPOINT").
|
||||
For commands that support both set and query forms,
|
||||
register the base keyword without '?'.
|
||||
handler: Callable that takes SCPICommand and returns response string.
|
||||
"""
|
||||
self._handlers[keyword.upper()] = handler
|
||||
|
||||
def process(self, command_string: str) -> str:
|
||||
"""Process a SCPI command string and return the response.
|
||||
|
||||
Args:
|
||||
command_string: Raw SCPI command string to process.
|
||||
|
||||
Returns:
|
||||
Response string. Empty string for commands with no response.
|
||||
Error string starting with "ERROR:" for invalid commands.
|
||||
"""
|
||||
command = self._parser.parse(command_string)
|
||||
|
||||
if not command.header:
|
||||
return ""
|
||||
|
||||
# Look up handler by keyword (without '?' suffix)
|
||||
keyword = command.keyword.upper()
|
||||
handler = self._handlers.get(keyword)
|
||||
|
||||
if handler is None:
|
||||
return f"ERROR: Unknown command '{keyword}'"
|
||||
|
||||
try:
|
||||
return handler(command)
|
||||
except ValueError as e:
|
||||
return f"ERROR: {e}"
|
||||
except Exception as e:
|
||||
return f"ERROR: Internal error - {e}"
|
||||
|
||||
def _handle_idn(self, command: SCPICommand) -> str:
|
||||
"""Handle *IDN? identification query.
|
||||
|
||||
Returns:
|
||||
Comma-separated identification string.
|
||||
"""
|
||||
if not command.is_query:
|
||||
return "ERROR: *IDN is query only"
|
||||
return f"{self.manufacturer},{self.model},{self.serial_number},{self.firmware_version}"
|
||||
|
||||
def _handle_rst(self, command: SCPICommand) -> str:
|
||||
"""Handle *RST reset command.
|
||||
|
||||
Returns:
|
||||
Empty string (no response for reset).
|
||||
"""
|
||||
if command.is_query:
|
||||
return "ERROR: *RST is command only"
|
||||
self.reset()
|
||||
return ""
|
||||
|
||||
def _handle_cls(self, command: SCPICommand) -> str:
|
||||
"""Handle *CLS clear status command.
|
||||
|
||||
Returns:
|
||||
Empty string (no response for clear).
|
||||
"""
|
||||
if command.is_query:
|
||||
return "ERROR: *CLS is command only"
|
||||
return ""
|
||||
|
||||
def _handle_opc(self, command: SCPICommand) -> str:
|
||||
"""Handle *OPC operation complete command/query.
|
||||
|
||||
Returns:
|
||||
"1" for query, empty string for command.
|
||||
"""
|
||||
if command.is_query:
|
||||
return "1"
|
||||
return ""
|
||||
|
||||
@abstractmethod
|
||||
def reset(self) -> None:
|
||||
"""Reset instrument to default state.
|
||||
|
||||
Subclasses must implement this to define reset behaviour.
|
||||
"""
|
||||
181
src/py_dvt_ate/simulation/virtual/chamber.py
Normal file
181
src/py_dvt_ate/simulation/virtual/chamber.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Virtual thermal chamber simulator.
|
||||
|
||||
This module implements a SCPI-based virtual thermal chamber that interfaces
|
||||
with the physics engine to provide realistic temperature control simulation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from py_dvt_ate.instruments.scpi import SCPICommand
|
||||
from py_dvt_ate.simulation.virtual.base import BaseInstrument
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
|
||||
|
||||
class ThermalChamberSim(BaseInstrument):
|
||||
"""Virtual thermal chamber simulator.
|
||||
|
||||
Simulates a thermal chamber with SCPI control interface. The chamber
|
||||
temperature behaviour is driven by the physics engine.
|
||||
|
||||
SCPI Commands:
|
||||
TEMP:SETPOINT <value> - Set target temperature in degrees C
|
||||
TEMP:SETPOINT? - Query current setpoint
|
||||
TEMP:ACTUAL? - Query actual chamber temperature
|
||||
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:
|
||||
manufacturer: "PyDVTATE"
|
||||
model: "TC-SIM-001"
|
||||
"""
|
||||
|
||||
manufacturer = "PyDVTATE"
|
||||
model = "TC-SIM-001"
|
||||
serial_number = "TCSIM001"
|
||||
firmware_version = "1.0.0"
|
||||
|
||||
# Stability threshold in degrees C
|
||||
STABILITY_THRESHOLD = 0.5
|
||||
|
||||
def __init__(self, physics_engine: PhysicsEngine | None = None) -> None:
|
||||
"""Initialise the thermal chamber simulator.
|
||||
|
||||
Args:
|
||||
physics_engine: Reference to physics engine for temperature state.
|
||||
"""
|
||||
self._setpoint = 25.0 # Default setpoint
|
||||
self._ramp_rate = 10.0 # Default ramp rate in degrees C/min
|
||||
super().__init__(physics_engine)
|
||||
|
||||
def _setup_commands(self) -> None:
|
||||
"""Register thermal chamber SCPI commands."""
|
||||
self.register_command("TEMP:SETPOINT", self._handle_temp_setpoint)
|
||||
self.register_command("TEMP:ACTUAL", self._handle_temp_actual)
|
||||
self.register_command("TEMP:STAB", self._handle_temp_stab)
|
||||
self.register_command("TEMP:RAMP", self._handle_temp_ramp)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset chamber to default state."""
|
||||
self._setpoint = 25.0
|
||||
self._ramp_rate = 10.0
|
||||
if self._physics_engine is not None:
|
||||
self._physics_engine.set_chamber_setpoint(self._setpoint)
|
||||
|
||||
def _handle_temp_setpoint(self, command: SCPICommand) -> str:
|
||||
"""Handle TEMP:SETPOINT command/query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Setpoint value for query, empty string for set command.
|
||||
|
||||
Raises:
|
||||
ValueError: If setpoint argument is invalid.
|
||||
"""
|
||||
if command.is_query:
|
||||
return f"{self._setpoint:.2f}"
|
||||
|
||||
# Set command requires one argument
|
||||
if not command.arguments:
|
||||
raise ValueError("TEMP:SETPOINT requires a value")
|
||||
|
||||
try:
|
||||
setpoint = float(command.arguments[0])
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Invalid temperature value: {command.arguments[0]}") from err
|
||||
|
||||
self._setpoint = setpoint
|
||||
if self._physics_engine is not None:
|
||||
self._physics_engine.set_chamber_setpoint(setpoint)
|
||||
|
||||
return ""
|
||||
|
||||
def _handle_temp_actual(self, command: SCPICommand) -> str:
|
||||
"""Handle TEMP:ACTUAL? query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Actual chamber temperature.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("TEMP:ACTUAL is query only")
|
||||
|
||||
if self._physics_engine is None:
|
||||
# Return setpoint if no physics engine connected
|
||||
return f"{self._setpoint:.2f}"
|
||||
|
||||
thermal_state = self._physics_engine.get_thermal_state()
|
||||
return f"{thermal_state.chamber_temperature:.2f}"
|
||||
|
||||
def _handle_temp_stab(self, command: SCPICommand) -> str:
|
||||
"""Handle TEMP:STAB? stability query.
|
||||
|
||||
Temperature is considered stable when the actual chamber temperature
|
||||
is within STABILITY_THRESHOLD of the setpoint.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
"1" if stable, "0" if settling.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("TEMP:STAB is query only")
|
||||
|
||||
if self._physics_engine is None:
|
||||
# Assume stable if no physics engine connected
|
||||
return "1"
|
||||
|
||||
thermal_state = self._physics_engine.get_thermal_state()
|
||||
error = abs(thermal_state.chamber_temperature - self._setpoint)
|
||||
|
||||
if error <= self.STABILITY_THRESHOLD:
|
||||
return "1"
|
||||
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 ""
|
||||
213
src/py_dvt_ate/simulation/virtual/multimeter.py
Normal file
213
src/py_dvt_ate/simulation/virtual/multimeter.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Virtual digital multimeter (DMM) simulator.
|
||||
|
||||
This module implements a SCPI-based virtual multimeter that interfaces
|
||||
with the physics engine to measure DUT electrical parameters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from py_dvt_ate.instruments.scpi import SCPICommand
|
||||
from py_dvt_ate.simulation.virtual.base import BaseInstrument
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
|
||||
|
||||
class MeasurementFunction(Enum):
|
||||
"""Available measurement functions."""
|
||||
|
||||
VOLTAGE_DC = "VOLT:DC"
|
||||
CURRENT_DC = "CURR:DC"
|
||||
|
||||
|
||||
class MultimeterSim(BaseInstrument):
|
||||
"""Virtual digital multimeter simulator.
|
||||
|
||||
Simulates a digital multimeter with SCPI control interface. The DMM
|
||||
measures DUT output voltage and load current via the physics engine.
|
||||
|
||||
SCPI Commands:
|
||||
MEAS:VOLT:DC? - Measure DC voltage (shortcut)
|
||||
MEAS:CURR:DC? - Measure DC current (shortcut)
|
||||
CONF:VOLT:DC - Configure for DC voltage measurement
|
||||
CONF:CURR:DC - Configure for DC current measurement
|
||||
CONF? - Query current configuration
|
||||
READ? - Take measurement with current configuration
|
||||
|
||||
Attributes:
|
||||
manufacturer: "PyDVTATE"
|
||||
model: "DMM-SIM-001"
|
||||
"""
|
||||
|
||||
manufacturer = "PyDVTATE"
|
||||
model = "DMM-SIM-001"
|
||||
serial_number = "DMMSIM001"
|
||||
firmware_version = "1.0.0"
|
||||
|
||||
def __init__(self, physics_engine: PhysicsEngine | None = None) -> None:
|
||||
"""Initialise the multimeter simulator.
|
||||
|
||||
Args:
|
||||
physics_engine: Reference to physics engine for measurement values.
|
||||
"""
|
||||
self._function = MeasurementFunction.VOLTAGE_DC
|
||||
super().__init__(physics_engine)
|
||||
|
||||
def _setup_commands(self) -> None:
|
||||
"""Register multimeter SCPI commands."""
|
||||
self.register_command("MEAS:VOLT:DC", self._handle_meas_volt_dc)
|
||||
self.register_command("MEAS:CURR:DC", self._handle_meas_curr_dc)
|
||||
self.register_command("CONF:VOLT:DC", self._handle_conf_volt_dc)
|
||||
self.register_command("CONF:CURR:DC", self._handle_conf_curr_dc)
|
||||
self.register_command("CONF", self._handle_conf)
|
||||
self.register_command("READ", self._handle_read)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset multimeter to default state."""
|
||||
self._function = MeasurementFunction.VOLTAGE_DC
|
||||
|
||||
def _handle_meas_volt_dc(self, command: SCPICommand) -> str:
|
||||
"""Handle MEAS:VOLT:DC? query.
|
||||
|
||||
Configures for DC voltage and takes measurement in one command.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Measured DC voltage.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("MEAS:VOLT:DC is query only")
|
||||
|
||||
self._function = MeasurementFunction.VOLTAGE_DC
|
||||
return self._measure_voltage_dc()
|
||||
|
||||
def _handle_meas_curr_dc(self, command: SCPICommand) -> str:
|
||||
"""Handle MEAS:CURR:DC? query.
|
||||
|
||||
Configures for DC current and takes measurement in one command.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Measured DC current.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("MEAS:CURR:DC is query only")
|
||||
|
||||
self._function = MeasurementFunction.CURRENT_DC
|
||||
return self._measure_current_dc()
|
||||
|
||||
def _handle_conf_volt_dc(self, command: SCPICommand) -> str:
|
||||
"""Handle CONF:VOLT:DC command.
|
||||
|
||||
Configures multimeter for DC voltage measurement.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Empty string (no response for configuration).
|
||||
|
||||
Raises:
|
||||
ValueError: If used as query.
|
||||
"""
|
||||
if command.is_query:
|
||||
raise ValueError("CONF:VOLT:DC is command only")
|
||||
|
||||
self._function = MeasurementFunction.VOLTAGE_DC
|
||||
return ""
|
||||
|
||||
def _handle_conf_curr_dc(self, command: SCPICommand) -> str:
|
||||
"""Handle CONF:CURR:DC command.
|
||||
|
||||
Configures multimeter for DC current measurement.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Empty string (no response for configuration).
|
||||
|
||||
Raises:
|
||||
ValueError: If used as query.
|
||||
"""
|
||||
if command.is_query:
|
||||
raise ValueError("CONF:CURR:DC is command only")
|
||||
|
||||
self._function = MeasurementFunction.CURRENT_DC
|
||||
return ""
|
||||
|
||||
def _handle_conf(self, command: SCPICommand) -> str:
|
||||
"""Handle CONF? query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Current measurement configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command without subcommand.
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("CONF requires a function (e.g., CONF:VOLT:DC)")
|
||||
|
||||
return f'"{self._function.value}"'
|
||||
|
||||
def _handle_read(self, command: SCPICommand) -> str:
|
||||
"""Handle READ? query.
|
||||
|
||||
Takes measurement using current configuration.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Measured value.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("READ is query only")
|
||||
|
||||
if self._function == MeasurementFunction.VOLTAGE_DC:
|
||||
return self._measure_voltage_dc()
|
||||
else:
|
||||
return self._measure_current_dc()
|
||||
|
||||
def _measure_voltage_dc(self) -> str:
|
||||
"""Measure DC voltage from physics engine.
|
||||
|
||||
Returns:
|
||||
Formatted voltage reading.
|
||||
"""
|
||||
if self._physics_engine is None:
|
||||
return "0.000000"
|
||||
|
||||
electrical_state = self._physics_engine.get_electrical_state()
|
||||
return f"{electrical_state.output_voltage:.6f}"
|
||||
|
||||
def _measure_current_dc(self) -> str:
|
||||
"""Measure DC current from physics engine.
|
||||
|
||||
Returns:
|
||||
Formatted current reading.
|
||||
"""
|
||||
if self._physics_engine is None:
|
||||
return "0.000000"
|
||||
|
||||
electrical_state = self._physics_engine.get_electrical_state()
|
||||
return f"{electrical_state.load_current:.6f}"
|
||||
222
src/py_dvt_ate/simulation/virtual/power_supply.py
Normal file
222
src/py_dvt_ate/simulation/virtual/power_supply.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Virtual power supply simulator.
|
||||
|
||||
This module implements a SCPI-based virtual power supply that interfaces
|
||||
with the physics engine to provide realistic power supply simulation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from py_dvt_ate.instruments.scpi import SCPICommand
|
||||
from py_dvt_ate.simulation.virtual.base import BaseInstrument
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
|
||||
|
||||
class PowerSupplySim(BaseInstrument):
|
||||
"""Virtual power supply simulator.
|
||||
|
||||
Simulates a programmable DC power supply with SCPI control interface.
|
||||
The power supply provides input voltage to the DUT via the physics engine.
|
||||
|
||||
SCPI Commands:
|
||||
VOLT <value> - Set output voltage in volts
|
||||
VOLT? - Query voltage setpoint
|
||||
CURR <value> - Set current limit in amps
|
||||
CURR? - Query current limit
|
||||
OUTP <ON|OFF|1|0> - Enable/disable output
|
||||
OUTP? - Query output state (1=on, 0=off)
|
||||
MEAS:VOLT? - Measure actual output voltage
|
||||
MEAS:CURR? - Measure actual output current
|
||||
|
||||
Attributes:
|
||||
manufacturer: "PyDVTATE"
|
||||
model: "PS-SIM-001"
|
||||
"""
|
||||
|
||||
manufacturer = "PyDVTATE"
|
||||
model = "PS-SIM-001"
|
||||
serial_number = "PSSIM001"
|
||||
firmware_version = "1.0.0"
|
||||
|
||||
# Default values
|
||||
DEFAULT_VOLTAGE = 0.0
|
||||
DEFAULT_CURRENT_LIMIT = 1.0
|
||||
|
||||
def __init__(self, physics_engine: PhysicsEngine | None = None) -> None:
|
||||
"""Initialise the power supply simulator.
|
||||
|
||||
Args:
|
||||
physics_engine: Reference to physics engine for electrical state.
|
||||
"""
|
||||
self._voltage_setpoint = self.DEFAULT_VOLTAGE
|
||||
self._current_limit = self.DEFAULT_CURRENT_LIMIT
|
||||
self._output_enabled = False
|
||||
super().__init__(physics_engine)
|
||||
|
||||
def _setup_commands(self) -> None:
|
||||
"""Register power supply SCPI commands."""
|
||||
self.register_command("VOLT", self._handle_volt)
|
||||
self.register_command("CURR", self._handle_curr)
|
||||
self.register_command("OUTP", self._handle_outp)
|
||||
self.register_command("MEAS:VOLT", self._handle_meas_volt)
|
||||
self.register_command("MEAS:CURR", self._handle_meas_curr)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset power supply to default state."""
|
||||
self._voltage_setpoint = self.DEFAULT_VOLTAGE
|
||||
self._current_limit = self.DEFAULT_CURRENT_LIMIT
|
||||
self._output_enabled = False
|
||||
|
||||
if self._physics_engine is not None:
|
||||
self._physics_engine.set_input_voltage(0.0)
|
||||
self._physics_engine.set_output_enabled(False)
|
||||
|
||||
def _handle_volt(self, command: SCPICommand) -> str:
|
||||
"""Handle VOLT command/query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Voltage setpoint for query, empty string for set command.
|
||||
|
||||
Raises:
|
||||
ValueError: If voltage argument is invalid.
|
||||
"""
|
||||
if command.is_query:
|
||||
return f"{self._voltage_setpoint:.3f}"
|
||||
|
||||
if not command.arguments:
|
||||
raise ValueError("VOLT requires a value")
|
||||
|
||||
try:
|
||||
voltage = float(command.arguments[0])
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Invalid voltage value: {command.arguments[0]}") from err
|
||||
|
||||
if voltage < 0:
|
||||
raise ValueError("Voltage cannot be negative")
|
||||
|
||||
self._voltage_setpoint = voltage
|
||||
|
||||
if self._physics_engine is not None and self._output_enabled:
|
||||
self._physics_engine.set_input_voltage(voltage)
|
||||
|
||||
return ""
|
||||
|
||||
def _handle_curr(self, command: SCPICommand) -> str:
|
||||
"""Handle CURR command/query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Current limit for query, empty string for set command.
|
||||
|
||||
Raises:
|
||||
ValueError: If current argument is invalid.
|
||||
"""
|
||||
if command.is_query:
|
||||
return f"{self._current_limit:.3f}"
|
||||
|
||||
if not command.arguments:
|
||||
raise ValueError("CURR requires a value")
|
||||
|
||||
try:
|
||||
current = float(command.arguments[0])
|
||||
except ValueError as err:
|
||||
raise ValueError(f"Invalid current value: {command.arguments[0]}") from err
|
||||
|
||||
if current < 0:
|
||||
raise ValueError("Current limit cannot be negative")
|
||||
|
||||
self._current_limit = current
|
||||
return ""
|
||||
|
||||
def _handle_outp(self, command: SCPICommand) -> str:
|
||||
"""Handle OUTP command/query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
"1" or "0" for query, empty string for set command.
|
||||
|
||||
Raises:
|
||||
ValueError: If output argument is invalid.
|
||||
"""
|
||||
if command.is_query:
|
||||
return "1" if self._output_enabled else "0"
|
||||
|
||||
if not command.arguments:
|
||||
raise ValueError("OUTP requires a value (ON, OFF, 1, or 0)")
|
||||
|
||||
arg = command.arguments[0].upper()
|
||||
if arg in ("ON", "1"):
|
||||
self._output_enabled = True
|
||||
elif arg in ("OFF", "0"):
|
||||
self._output_enabled = False
|
||||
else:
|
||||
raise ValueError(f"Invalid output state: {command.arguments[0]}")
|
||||
|
||||
if self._physics_engine is not None:
|
||||
self._physics_engine.set_output_enabled(self._output_enabled)
|
||||
if self._output_enabled:
|
||||
self._physics_engine.set_input_voltage(self._voltage_setpoint)
|
||||
else:
|
||||
self._physics_engine.set_input_voltage(0.0)
|
||||
|
||||
return ""
|
||||
|
||||
def _handle_meas_volt(self, command: SCPICommand) -> str:
|
||||
"""Handle MEAS:VOLT? query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Measured output voltage.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("MEAS:VOLT is query only")
|
||||
|
||||
if not self._output_enabled:
|
||||
return "0.000"
|
||||
|
||||
if self._physics_engine is None:
|
||||
return f"{self._voltage_setpoint:.3f}"
|
||||
|
||||
electrical_state = self._physics_engine.get_electrical_state()
|
||||
return f"{electrical_state.input_voltage:.3f}"
|
||||
|
||||
def _handle_meas_curr(self, command: SCPICommand) -> str:
|
||||
"""Handle MEAS:CURR? query.
|
||||
|
||||
Args:
|
||||
command: Parsed SCPI command.
|
||||
|
||||
Returns:
|
||||
Measured output current.
|
||||
|
||||
Raises:
|
||||
ValueError: If used as command (not query).
|
||||
"""
|
||||
if not command.is_query:
|
||||
raise ValueError("MEAS:CURR is query only")
|
||||
|
||||
if not self._output_enabled:
|
||||
return "0.000"
|
||||
|
||||
if self._physics_engine is None:
|
||||
return "0.000"
|
||||
|
||||
electrical_state = self._physics_engine.get_electrical_state()
|
||||
# Total current is load current + quiescent current
|
||||
total_current = electrical_state.load_current + electrical_state.quiescent_current
|
||||
return f"{total_current:.3f}"
|
||||
158
src/py_dvt_ate/tests/base.py
Normal file
158
src/py_dvt_ate/tests/base.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Base class and utilities for DVT test implementations.
|
||||
|
||||
This module provides common functionality shared across all DVT tests,
|
||||
including thermal settling helpers, measurement utilities, and statistical
|
||||
calculations.
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import ABC
|
||||
from collections.abc import Callable
|
||||
|
||||
from py_dvt_ate.framework.context import ITest, TestContext
|
||||
|
||||
|
||||
class BaseDVTTest(ITest, ABC):
|
||||
"""Abstract base class for DVT tests with common utilities.
|
||||
|
||||
Provides helper methods for thermal settling, measurement averaging,
|
||||
and other common test patterns. All DVT tests should inherit from
|
||||
this class rather than directly from ITest.
|
||||
"""
|
||||
|
||||
def wait_for_temperature(
|
||||
self,
|
||||
context: TestContext,
|
||||
setpoint: float,
|
||||
timeout: float = 300.0,
|
||||
poll_interval: float = 1.0,
|
||||
) -> bool:
|
||||
"""Wait for thermal chamber to stabilise at setpoint.
|
||||
|
||||
Sets the chamber temperature and waits until stable. Logs progress
|
||||
to the test logger.
|
||||
|
||||
Args:
|
||||
context: Test context with instruments and logger.
|
||||
setpoint: Target temperature in degrees Celsius.
|
||||
timeout: Maximum wait time in seconds. Default 300s (5 minutes).
|
||||
poll_interval: Time between stability checks. Default 1s.
|
||||
|
||||
Returns:
|
||||
True if temperature stabilised within timeout, False if timed out.
|
||||
|
||||
Raises:
|
||||
ConnectionError: If instrument communication fails.
|
||||
IOError: If instrument reports error.
|
||||
"""
|
||||
chamber = context.instruments.chamber
|
||||
|
||||
# Set the temperature
|
||||
chamber.set_temperature(setpoint)
|
||||
context.logger.log_event(
|
||||
f"Set thermal chamber to {setpoint:.1f}°C, waiting for stability...",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
# Wait for stability
|
||||
start_time = time.time()
|
||||
elapsed = 0.0
|
||||
|
||||
while elapsed < timeout:
|
||||
if chamber.is_stable():
|
||||
actual = chamber.get_temperature()
|
||||
context.logger.log_event(
|
||||
f"Chamber stable at {actual:.2f}°C "
|
||||
f"(target {setpoint:.1f}°C) after {elapsed:.1f}s",
|
||||
level="INFO",
|
||||
)
|
||||
return True
|
||||
|
||||
time.sleep(poll_interval)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Timeout
|
||||
actual = chamber.get_temperature()
|
||||
context.logger.log_event(
|
||||
f"Timeout waiting for stability. Chamber at {actual:.2f}°C, "
|
||||
f"target {setpoint:.1f}°C after {timeout:.1f}s",
|
||||
level="WARNING",
|
||||
)
|
||||
return False
|
||||
|
||||
def measure_averaged(
|
||||
self,
|
||||
measurement_func: Callable[[], float],
|
||||
num_samples: int = 5,
|
||||
settle_time: float = 0.1,
|
||||
) -> tuple[float, float]:
|
||||
"""Take multiple measurements and return mean and standard deviation.
|
||||
|
||||
Useful for reducing noise in measurements by averaging multiple samples.
|
||||
|
||||
Args:
|
||||
measurement_func: Function that returns a single measurement.
|
||||
num_samples: Number of samples to average. Default 5.
|
||||
settle_time: Delay between samples in seconds. Default 0.1s.
|
||||
|
||||
Returns:
|
||||
Tuple of (mean, standard_deviation).
|
||||
|
||||
Raises:
|
||||
ValueError: If num_samples < 1.
|
||||
Exception: If measurement_func raises an exception.
|
||||
"""
|
||||
if num_samples < 1:
|
||||
raise ValueError("num_samples must be at least 1")
|
||||
|
||||
samples: list[float] = []
|
||||
for _ in range(num_samples):
|
||||
if settle_time > 0 and len(samples) > 0:
|
||||
time.sleep(settle_time)
|
||||
samples.append(measurement_func())
|
||||
|
||||
mean = sum(samples) / len(samples)
|
||||
|
||||
if len(samples) == 1:
|
||||
std_dev = 0.0
|
||||
else:
|
||||
variance = sum((x - mean) ** 2 for x in samples) / (len(samples) - 1)
|
||||
std_dev = variance ** 0.5
|
||||
|
||||
return mean, std_dev
|
||||
|
||||
def thermal_settle(
|
||||
self,
|
||||
context: TestContext,
|
||||
additional_settle_time: float = 5.0,
|
||||
) -> None:
|
||||
"""Wait for additional thermal settling after chamber reports stable.
|
||||
|
||||
After the chamber reports stable temperature, this adds additional
|
||||
settling time to ensure the DUT junction temperature has also stabilised.
|
||||
This is important for measurements sensitive to self-heating effects.
|
||||
|
||||
Args:
|
||||
context: Test context with logger.
|
||||
additional_settle_time: Extra settling time in seconds. Default 5s.
|
||||
"""
|
||||
if additional_settle_time > 0:
|
||||
context.logger.log_event(
|
||||
f"Additional thermal settling for {additional_settle_time:.1f}s...",
|
||||
level="INFO",
|
||||
)
|
||||
time.sleep(additional_settle_time)
|
||||
|
||||
def delay(self, seconds: float, message: str | None = None) -> None:
|
||||
"""Sleep for specified duration.
|
||||
|
||||
Simple utility for adding delays in test sequences.
|
||||
|
||||
Args:
|
||||
seconds: Delay duration in seconds.
|
||||
message: Optional message describing reason for delay.
|
||||
"""
|
||||
if message:
|
||||
# Could log this if needed
|
||||
pass
|
||||
time.sleep(seconds)
|
||||
243
src/py_dvt_ate/tests/thermal/tempco.py
Normal file
243
src/py_dvt_ate/tests/thermal/tempco.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Temperature Coefficient (TempCo) characterisation test.
|
||||
|
||||
This test characterises the output voltage temperature coefficient by
|
||||
sweeping the chamber temperature and measuring output voltage at each point.
|
||||
The TempCo is calculated from the linear regression slope and expressed
|
||||
in parts per million per degree Celsius (ppm/C).
|
||||
"""
|
||||
|
||||
from py_dvt_ate.data.models import TestStatus
|
||||
from py_dvt_ate.framework.context import TestContext
|
||||
from py_dvt_ate.tests.base import BaseDVTTest
|
||||
|
||||
|
||||
class TempCoTest(BaseDVTTest):
|
||||
"""Temperature coefficient characterisation test.
|
||||
|
||||
Measures how output voltage varies with temperature. This is a critical
|
||||
parameter for voltage regulators, as it indicates stability across
|
||||
the operating temperature range.
|
||||
|
||||
Test Procedure:
|
||||
1. Configure DUT supply voltage and load current
|
||||
2. Sweep chamber temperature from min to max
|
||||
3. At each temperature point:
|
||||
- Wait for thermal stability
|
||||
- Measure output voltage (averaged)
|
||||
- Log measurement with conditions
|
||||
4. Calculate TempCo from linear regression
|
||||
5. Evaluate against specification limits
|
||||
|
||||
Configuration:
|
||||
temperatures: List of temperature points (C). Default: [-40, -20, 0, 25, 50, 85]
|
||||
input_voltage: DUT input voltage (V). Default: 5.0
|
||||
load_current: DUT load current (A). Default: 0.1
|
||||
settle_time: Additional settling time at each temp (s). Default: 5.0
|
||||
num_samples: Number of measurements to average per point. Default: 5
|
||||
tempco_limit: Maximum allowed TempCo magnitude (ppm/C). Default: +/-50.0
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return test identifier."""
|
||||
return "tempco"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Return test description."""
|
||||
return "Output voltage temperature coefficient"
|
||||
|
||||
def execute(self, context: TestContext) -> TestStatus:
|
||||
"""Execute TempCo characterisation test.
|
||||
|
||||
Args:
|
||||
context: Test context with instruments, logger, and configuration.
|
||||
|
||||
Returns:
|
||||
PASSED if TempCo is within limits, FAILED otherwise.
|
||||
ERROR if a critical failure occurs.
|
||||
"""
|
||||
try:
|
||||
# Get configuration
|
||||
config = context.config
|
||||
temperatures = config.get("temperatures", [-40.0, -20.0, 0.0, 25.0, 50.0, 85.0])
|
||||
input_voltage = config.get("input_voltage", 5.0)
|
||||
load_current = config.get("load_current", 0.1)
|
||||
settle_time = config.get("settle_time", 5.0)
|
||||
num_samples = config.get("num_samples", 5)
|
||||
tempco_limit = config.get("tempco_limit", 50.0)
|
||||
|
||||
context.logger.log_event(
|
||||
f"Starting TempCo test: {len(temperatures)} temperature points, "
|
||||
f"Vin={input_voltage}V, Iload={load_current}A",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
# Configure DUT power
|
||||
context.logger.log_event(
|
||||
f"Configuring PSU: Vin={input_voltage}V, Ilimit={load_current + 0.5}A",
|
||||
level="INFO",
|
||||
)
|
||||
psu = context.instruments.psu
|
||||
psu.set_voltage(1, input_voltage)
|
||||
psu.set_current_limit(1, load_current + 0.5) # Add headroom
|
||||
psu.enable_output(1, True)
|
||||
|
||||
# Storage for measurements
|
||||
temp_points: list[float] = []
|
||||
vout_points: list[float] = []
|
||||
|
||||
# Temperature sweep
|
||||
for temp_setpoint in temperatures:
|
||||
context.logger.log_event(
|
||||
f"Temperature point: {temp_setpoint}C",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
# Wait for thermal stability
|
||||
stable = self.wait_for_temperature(
|
||||
context,
|
||||
temp_setpoint,
|
||||
timeout=300.0,
|
||||
)
|
||||
if not stable:
|
||||
context.logger.log_event(
|
||||
f"Warning: Temperature did not stabilise at {temp_setpoint}C",
|
||||
level="WARNING",
|
||||
)
|
||||
|
||||
# Additional settling for DUT junction temperature
|
||||
self.thermal_settle(context, settle_time)
|
||||
|
||||
# Measure output voltage (averaged)
|
||||
actual_temp = context.instruments.chamber.get_temperature()
|
||||
|
||||
def measure_vout() -> float:
|
||||
return context.instruments.dmm.measure_dc_voltage()
|
||||
|
||||
vout_mean, vout_std = self.measure_averaged(
|
||||
measure_vout,
|
||||
num_samples=num_samples,
|
||||
)
|
||||
|
||||
# Log individual measurement
|
||||
context.logger.log_measurement(
|
||||
parameter="v_out",
|
||||
value=vout_mean,
|
||||
unit="V",
|
||||
conditions={
|
||||
"temperature": actual_temp,
|
||||
"input_voltage": input_voltage,
|
||||
"load_current": load_current,
|
||||
},
|
||||
)
|
||||
|
||||
context.logger.log_event(
|
||||
f"Measured Vout = {vout_mean:.6f}V +/- {vout_std * 1e6:.1f}uV "
|
||||
f"at T={actual_temp:.2f}C",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
# Store for TempCo calculation
|
||||
temp_points.append(actual_temp)
|
||||
vout_points.append(vout_mean)
|
||||
|
||||
# Calculate TempCo from linear regression
|
||||
tempco_ppm = self._calculate_tempco(temp_points, vout_points)
|
||||
|
||||
context.logger.log_event(
|
||||
f"Calculated TempCo = {tempco_ppm:.2f} ppm/C",
|
||||
level="INFO",
|
||||
)
|
||||
|
||||
# Log result with limits
|
||||
context.logger.log_result(
|
||||
parameter="temp_co",
|
||||
value=tempco_ppm,
|
||||
unit="ppm/C",
|
||||
lower_limit=-abs(tempco_limit),
|
||||
upper_limit=abs(tempco_limit),
|
||||
)
|
||||
|
||||
# Evaluate pass/fail
|
||||
passed = abs(tempco_ppm) <= tempco_limit
|
||||
|
||||
if passed:
|
||||
context.logger.log_event(
|
||||
f"TempCo test PASSED: {tempco_ppm:.2f} ppm/C within +/-{tempco_limit} ppm/C",
|
||||
level="INFO",
|
||||
)
|
||||
return TestStatus.PASSED
|
||||
else:
|
||||
context.logger.log_event(
|
||||
f"TempCo test FAILED: {tempco_ppm:.2f} ppm/C exceeds +/-{tempco_limit} ppm/C",
|
||||
level="ERROR",
|
||||
)
|
||||
return TestStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
context.logger.log_event(
|
||||
f"TempCo test ERROR: {e!s}",
|
||||
level="ERROR",
|
||||
)
|
||||
return TestStatus.ERROR
|
||||
|
||||
finally:
|
||||
# Cleanup: disable PSU output
|
||||
try:
|
||||
context.instruments.psu.enable_output(1, False)
|
||||
context.logger.log_event("PSU output disabled", level="INFO")
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
|
||||
def _calculate_tempco(
|
||||
self,
|
||||
temperatures: list[float],
|
||||
voltages: list[float],
|
||||
) -> float:
|
||||
"""Calculate temperature coefficient from measurements.
|
||||
|
||||
Uses linear regression to find the slope (dV/dT), then converts
|
||||
to ppm/C relative to the nominal voltage (voltage at median temperature).
|
||||
|
||||
Args:
|
||||
temperatures: Temperature measurements in C.
|
||||
voltages: Output voltage measurements in V.
|
||||
|
||||
Returns:
|
||||
Temperature coefficient in ppm/C.
|
||||
|
||||
Raises:
|
||||
ValueError: If insufficient data points.
|
||||
"""
|
||||
if len(temperatures) < 2 or len(temperatures) != len(voltages):
|
||||
raise ValueError("Need at least 2 matching temperature-voltage pairs")
|
||||
|
||||
n = len(temperatures)
|
||||
|
||||
# Linear regression: V = a + b*T
|
||||
# We want slope b = dV/dT
|
||||
mean_t = sum(temperatures) / n
|
||||
mean_v = sum(voltages) / n
|
||||
|
||||
# Covariance and variance
|
||||
cov = sum(
|
||||
(t - mean_t) * (v - mean_v)
|
||||
for t, v in zip(temperatures, voltages, strict=True)
|
||||
)
|
||||
var_t = sum((t - mean_t) ** 2 for t in temperatures)
|
||||
|
||||
if var_t == 0:
|
||||
raise ValueError("Temperature variance is zero (all temps identical)")
|
||||
|
||||
slope = cov / var_t # dV/dT in V/C
|
||||
|
||||
# Find nominal voltage (voltage at median temperature)
|
||||
sorted_pairs = sorted(zip(temperatures, voltages, strict=True))
|
||||
mid_idx = len(sorted_pairs) // 2
|
||||
v_nominal = sorted_pairs[mid_idx][1]
|
||||
|
||||
# Convert to ppm/C: (dV/dT) / V_nom * 10^6
|
||||
tempco_ppm = (slope / v_nominal) * 1e6
|
||||
|
||||
return tempco_ppm
|
||||
@@ -1 +1,8 @@
|
||||
"""pytest fixtures for py_dvt_ate tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
"""Configure pytest markers."""
|
||||
config.addinivalue_line("markers", "asyncio: mark test as async")
|
||||
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integration tests for py_dvt_ate."""
|
||||
135
tests/integration/conftest.py
Normal file
135
tests/integration/conftest.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""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
|
||||
275
tests/integration/test_tcp_server.py
Normal file
275
tests/integration/test_tcp_server.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Integration tests for TCP server.
|
||||
|
||||
Tests the InstrumentServer and SimulationServer with actual TCP connections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.instruments.transport import InstrumentServer
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
||||
from py_dvt_ate.simulation.virtual.chamber import ThermalChamberSim
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
class TestInstrumentServer:
|
||||
"""Tests for InstrumentServer TCP functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def physics_engine(self) -> PhysicsEngine:
|
||||
"""Create a physics engine for testing."""
|
||||
return PhysicsEngine(update_rate_hz=100.0)
|
||||
|
||||
@pytest.fixture
|
||||
def server(self, physics_engine: PhysicsEngine) -> InstrumentServer:
|
||||
"""Create an instrument server with a thermal chamber."""
|
||||
server = InstrumentServer(host="127.0.0.1")
|
||||
chamber = ThermalChamberSim(physics_engine)
|
||||
server.register_instrument(15000, chamber)
|
||||
return server
|
||||
|
||||
async def test_server_start_stop(self, server: InstrumentServer) -> None:
|
||||
"""Test server can start and stop."""
|
||||
assert not server.is_running
|
||||
|
||||
await server.start()
|
||||
assert server.is_running
|
||||
|
||||
await server.stop()
|
||||
assert not server.is_running
|
||||
|
||||
async def test_client_connection(self, server: InstrumentServer) -> None:
|
||||
"""Test client can connect and send command."""
|
||||
await server.start()
|
||||
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", 15000)
|
||||
|
||||
# Send *IDN? query
|
||||
writer.write(b"*IDN?\n")
|
||||
await writer.drain()
|
||||
|
||||
# Read response
|
||||
response = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
||||
assert b"PyDVTATE" in response
|
||||
assert b"TC-SIM-001" in response
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
async def test_multiple_commands(self, server: InstrumentServer) -> None:
|
||||
"""Test sending multiple commands in sequence."""
|
||||
await server.start()
|
||||
|
||||
try:
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", 15000)
|
||||
|
||||
# Set temperature setpoint
|
||||
writer.write(b"TEMP:SETPOINT 85.0\n")
|
||||
await writer.drain()
|
||||
# Small delay to ensure server processes command before next one
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Query setpoint
|
||||
writer.write(b"TEMP:SETPOINT?\n")
|
||||
await writer.drain()
|
||||
response = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
||||
assert b"85.00" in response
|
||||
|
||||
# Query actual temperature
|
||||
writer.write(b"TEMP:ACTUAL?\n")
|
||||
await writer.drain()
|
||||
response = await asyncio.wait_for(reader.readline(), timeout=2.0)
|
||||
# Should return a valid float
|
||||
temp = float(response.decode().strip())
|
||||
assert -50 <= temp <= 200
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
async def test_concurrent_connections(
|
||||
self, physics_engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test multiple concurrent client connections."""
|
||||
server = InstrumentServer(host="127.0.0.1")
|
||||
chamber = ThermalChamberSim(physics_engine)
|
||||
server.register_instrument(15001, chamber)
|
||||
|
||||
await server.start()
|
||||
|
||||
try:
|
||||
# Connect two clients simultaneously
|
||||
reader1, writer1 = await asyncio.open_connection("127.0.0.1", 15001)
|
||||
reader2, writer2 = await asyncio.open_connection("127.0.0.1", 15001)
|
||||
|
||||
# Send command from client 1
|
||||
writer1.write(b"*IDN?\n")
|
||||
await writer1.drain()
|
||||
response1 = await asyncio.wait_for(reader1.readline(), timeout=2.0)
|
||||
|
||||
# Send command from client 2
|
||||
writer2.write(b"*IDN?\n")
|
||||
await writer2.drain()
|
||||
response2 = await asyncio.wait_for(reader2.readline(), timeout=2.0)
|
||||
|
||||
# Both should get valid responses
|
||||
assert b"TC-SIM-001" in response1
|
||||
assert b"TC-SIM-001" in response2
|
||||
|
||||
writer1.close()
|
||||
writer2.close()
|
||||
await writer1.wait_closed()
|
||||
await writer2.wait_closed()
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="function")
|
||||
class TestSimulationServer:
|
||||
"""Tests for complete SimulationServer."""
|
||||
|
||||
async def test_simulation_server_start_stop(self) -> None:
|
||||
"""Test simulation server lifecycle."""
|
||||
config = ServerConfig(
|
||||
host="127.0.0.1",
|
||||
chamber_port=16000,
|
||||
psu_port=16001,
|
||||
dmm_port=16002,
|
||||
physics_rate_hz=100.0,
|
||||
)
|
||||
server = SimulationServer(config)
|
||||
|
||||
assert not server.is_running
|
||||
|
||||
await server.start()
|
||||
assert server.is_running
|
||||
assert server.physics_engine is not None
|
||||
|
||||
await server.stop()
|
||||
assert not server.is_running
|
||||
|
||||
async def test_all_instruments_accessible(self) -> None:
|
||||
"""Test all three instruments are accessible over TCP."""
|
||||
config = ServerConfig(
|
||||
host="127.0.0.1",
|
||||
chamber_port=16100,
|
||||
psu_port=16101,
|
||||
dmm_port=16102,
|
||||
)
|
||||
server = SimulationServer(config)
|
||||
await server.start()
|
||||
|
||||
try:
|
||||
# Test thermal chamber
|
||||
r, w = await asyncio.open_connection("127.0.0.1", 16100)
|
||||
w.write(b"*IDN?\n")
|
||||
await w.drain()
|
||||
resp = await asyncio.wait_for(r.readline(), timeout=2.0)
|
||||
assert b"TC-SIM-001" in resp
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
|
||||
# Test power supply
|
||||
r, w = await asyncio.open_connection("127.0.0.1", 16101)
|
||||
w.write(b"*IDN?\n")
|
||||
await w.drain()
|
||||
resp = await asyncio.wait_for(r.readline(), timeout=2.0)
|
||||
assert b"PS-SIM-001" in resp
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
|
||||
# Test multimeter
|
||||
r, w = await asyncio.open_connection("127.0.0.1", 16102)
|
||||
w.write(b"*IDN?\n")
|
||||
await w.drain()
|
||||
resp = await asyncio.wait_for(r.readline(), timeout=2.0)
|
||||
assert b"DMM-SIM-001" in resp
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
async def test_physics_engine_integration(self) -> None:
|
||||
"""Test instruments share physics engine state."""
|
||||
config = ServerConfig(
|
||||
host="127.0.0.1",
|
||||
chamber_port=16200,
|
||||
psu_port=16201,
|
||||
dmm_port=16202,
|
||||
)
|
||||
server = SimulationServer(config)
|
||||
await server.start()
|
||||
|
||||
try:
|
||||
# Connect to power supply and enable output
|
||||
psu_r, psu_w = await asyncio.open_connection("127.0.0.1", 16201)
|
||||
psu_w.write(b"VOLT 5.0\n")
|
||||
await psu_w.drain()
|
||||
await asyncio.sleep(0.01) # Allow server to process
|
||||
psu_w.write(b"OUTP ON\n")
|
||||
await psu_w.drain()
|
||||
|
||||
# Run a few physics steps
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Query voltage from power supply
|
||||
psu_w.write(b"MEAS:VOLT?\n")
|
||||
await psu_w.drain()
|
||||
psu_resp = await asyncio.wait_for(psu_r.readline(), timeout=2.0)
|
||||
psu_voltage = float(psu_resp.decode().strip())
|
||||
|
||||
# Connect to DMM and measure DUT output
|
||||
dmm_r, dmm_w = await asyncio.open_connection("127.0.0.1", 16202)
|
||||
dmm_w.write(b"MEAS:VOLT:DC?\n")
|
||||
await dmm_w.drain()
|
||||
dmm_resp = await asyncio.wait_for(dmm_r.readline(), timeout=2.0)
|
||||
dmm_voltage = float(dmm_resp.decode().strip())
|
||||
|
||||
# PSU should show input voltage (5V)
|
||||
assert 4.9 <= psu_voltage <= 5.1
|
||||
|
||||
# DMM should show DUT output voltage (LDO regulated ~3.3V)
|
||||
assert 3.0 <= dmm_voltage <= 3.5
|
||||
|
||||
psu_w.close()
|
||||
dmm_w.close()
|
||||
await psu_w.wait_closed()
|
||||
await dmm_w.wait_closed()
|
||||
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
async def test_error_handling(self) -> None:
|
||||
"""Test invalid commands return errors."""
|
||||
config = ServerConfig(
|
||||
host="127.0.0.1",
|
||||
chamber_port=16300,
|
||||
psu_port=16301,
|
||||
dmm_port=16302,
|
||||
)
|
||||
server = SimulationServer(config)
|
||||
await server.start()
|
||||
|
||||
try:
|
||||
r, w = await asyncio.open_connection("127.0.0.1", 16300)
|
||||
|
||||
# Send invalid command
|
||||
w.write(b"INVALID:COMMAND\n")
|
||||
await w.drain()
|
||||
resp = await asyncio.wait_for(r.readline(), timeout=2.0)
|
||||
assert b"ERROR" in resp
|
||||
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
|
||||
finally:
|
||||
await server.stop()
|
||||
250
tests/integration/test_tempco.py
Normal file
250
tests/integration/test_tempco.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Integration tests for TempCo characterisation test.
|
||||
|
||||
Full end-to-end test of the TempCo test with simulated instruments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from py_dvt_ate.data.models import TestStatus
|
||||
from py_dvt_ate.data.repository import SQLiteRepository
|
||||
from py_dvt_ate.framework.context import TestContext
|
||||
from py_dvt_ate.framework.logger import TestLogger
|
||||
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
|
||||
|
||||
|
||||
class TestTempCoIntegration:
|
||||
"""Integration tests for TempCo test with simulator."""
|
||||
|
||||
def test_tempco_runs_successfully(
|
||||
self, tmp_path: Path, simulation_server: ServerConfig
|
||||
) -> None:
|
||||
"""Test TempCo test runs end-to-end with simulator."""
|
||||
# Create instrument set connected to simulator
|
||||
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)
|
||||
|
||||
# Create test repository
|
||||
db_path = tmp_path / "test.db"
|
||||
repository = SQLiteRepository(db_path)
|
||||
|
||||
# Create test run
|
||||
run_id = repository.create_run(
|
||||
test_name="tempco",
|
||||
config={
|
||||
"temperatures": [23.0, 25.0, 27.0], # Close to start temp for fast settling
|
||||
"input_voltage": 5.0,
|
||||
"load_current": 0.1,
|
||||
"settle_time": 0.2, # Short since temps close to start
|
||||
"num_samples": 3, # Reduced for faster test
|
||||
"tempco_limit": 100.0, # Relaxed for testing
|
||||
},
|
||||
description="Integration test of TempCo",
|
||||
)
|
||||
|
||||
# Create test logger
|
||||
logger = TestLogger(run_id, repository)
|
||||
|
||||
# Create test context
|
||||
context = TestContext(
|
||||
run_id=run_id,
|
||||
instruments=instruments,
|
||||
logger=logger,
|
||||
config={
|
||||
"temperatures": [23.0, 25.0, 27.0],
|
||||
"input_voltage": 5.0,
|
||||
"load_current": 0.1,
|
||||
"settle_time": 0.2,
|
||||
"num_samples": 3,
|
||||
"tempco_limit": 100.0,
|
||||
},
|
||||
)
|
||||
|
||||
# Create test
|
||||
test = TempCoTest()
|
||||
assert test.name == "tempco"
|
||||
assert test.description == "Output voltage temperature coefficient"
|
||||
|
||||
# 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)
|
||||
|
||||
# Verify test completed
|
||||
assert status in (TestStatus.PASSED, TestStatus.FAILED)
|
||||
|
||||
# Flush logger to ensure all data is written
|
||||
logger.flush()
|
||||
|
||||
# Update run status
|
||||
repository.complete_run(run_id, status)
|
||||
|
||||
# Verify results were logged
|
||||
results = repository.get_results(run_id)
|
||||
assert len(results) > 0
|
||||
|
||||
# Find TempCo result
|
||||
tempco_result = next(r for r in results if r.parameter == "temp_co")
|
||||
assert tempco_result is not None
|
||||
assert tempco_result.unit == "ppm/C"
|
||||
assert tempco_result.lower_limit == -100.0
|
||||
assert tempco_result.upper_limit == 100.0
|
||||
|
||||
# Verify measurements were logged
|
||||
df = repository.get_measurements_dataframe(run_id)
|
||||
assert df is not None
|
||||
assert len(df) >= 3 # At least 3 temperature points
|
||||
|
||||
# Verify v_out measurements exist
|
||||
vout_measurements = df[df["parameter"] == "v_out"]
|
||||
assert len(vout_measurements) >= 3
|
||||
|
||||
# Verify temperature conditions were logged
|
||||
assert "temperature" in df.columns
|
||||
temps_recorded = vout_measurements["temperature"].unique()
|
||||
assert len(temps_recorded) >= 3
|
||||
|
||||
finally:
|
||||
# Disconnect from instruments
|
||||
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||
|
||||
def test_tempco_with_minimal_config(
|
||||
self, tmp_path: Path, simulation_server: ServerConfig
|
||||
) -> None:
|
||||
"""Test TempCo uses default configuration when not specified."""
|
||||
# Create instrument set
|
||||
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)
|
||||
|
||||
# Create repository
|
||||
db_path = tmp_path / "test_minimal.db"
|
||||
repository = SQLiteRepository(db_path)
|
||||
run_id = repository.create_run(
|
||||
test_name="tempco",
|
||||
config={}, # Empty config - should use defaults
|
||||
)
|
||||
|
||||
# Create logger and context with minimal config
|
||||
logger = TestLogger(run_id, repository)
|
||||
context = TestContext(
|
||||
run_id=run_id,
|
||||
instruments=instruments,
|
||||
logger=logger,
|
||||
config={
|
||||
# Override temperatures for faster test
|
||||
"temperatures": [24.0, 26.0],
|
||||
"settle_time": 0.2,
|
||||
"num_samples": 2,
|
||||
},
|
||||
)
|
||||
|
||||
# Execute test
|
||||
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)
|
||||
|
||||
# Should complete without error
|
||||
assert status in (TestStatus.PASSED, TestStatus.FAILED, TestStatus.ERROR)
|
||||
|
||||
logger.flush()
|
||||
repository.complete_run(run_id, status)
|
||||
|
||||
# Verify some data was logged
|
||||
results = repository.get_results(run_id)
|
||||
assert len(results) >= 1
|
||||
|
||||
finally:
|
||||
# Disconnect from instruments
|
||||
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||
|
||||
def test_tempco_handles_errors_gracefully(
|
||||
self, tmp_path: Path, simulation_server: ServerConfig
|
||||
) -> None:
|
||||
"""Test TempCo returns ERROR status when instruments fail."""
|
||||
# Create instrument set
|
||||
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)
|
||||
|
||||
# Create repository
|
||||
db_path = tmp_path / "test_error.db"
|
||||
repository = SQLiteRepository(db_path)
|
||||
run_id = repository.create_run(test_name="tempco", config={})
|
||||
|
||||
# Create logger and context
|
||||
logger = TestLogger(run_id, repository)
|
||||
context = TestContext(
|
||||
run_id=run_id,
|
||||
instruments=instruments,
|
||||
logger=logger,
|
||||
config={
|
||||
"temperatures": [], # Invalid: empty temperature list
|
||||
"settle_time": 0.1,
|
||||
},
|
||||
)
|
||||
|
||||
# Execute test
|
||||
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)
|
||||
# The test should not raise an unhandled exception
|
||||
try:
|
||||
status = test.execute(context)
|
||||
# If it completes, it should indicate an error or failure
|
||||
assert status in (TestStatus.ERROR, TestStatus.FAILED)
|
||||
except Exception:
|
||||
# Or it might raise, which we also consider handled
|
||||
pass
|
||||
|
||||
logger.flush()
|
||||
|
||||
finally:
|
||||
# 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
|
||||
266
tests/unit/test_config.py
Normal file
266
tests/unit/test_config.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Tests for configuration loading and validation."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from py_dvt_ate.app.config import (
|
||||
APIConfig,
|
||||
AppConfig,
|
||||
ChamberConfig,
|
||||
DashboardConfig,
|
||||
DataConfig,
|
||||
DUTConfig,
|
||||
DUTParameters,
|
||||
InstrumentsConfig,
|
||||
LoggingConfig,
|
||||
PhysicsConfig,
|
||||
PyVISAConfig,
|
||||
SimulatorConfig,
|
||||
ThermalConfig,
|
||||
load_config,
|
||||
)
|
||||
|
||||
|
||||
def test_default_config_values() -> None:
|
||||
"""Test that default configuration values are correct."""
|
||||
config = AppConfig()
|
||||
|
||||
assert config.instruments.backend == "simulator"
|
||||
assert config.instruments.simulator.host == "localhost"
|
||||
assert config.instruments.simulator.thermal_chamber_port == 5001
|
||||
|
||||
assert config.physics.update_rate_hz == 100.0
|
||||
assert config.physics.thermal.chamber_time_constant_s == 30.0
|
||||
assert config.physics.thermal.theta_jc == 15.0
|
||||
|
||||
assert config.dut.model == "ldo"
|
||||
assert config.dut.parameters.nominal_output_voltage == 3.3
|
||||
assert config.dut.parameters.tempco_ppm_per_c == 50.0
|
||||
|
||||
assert config.data.database_path == "./data/py_dvt_ate.db"
|
||||
assert config.logging.level == "INFO"
|
||||
assert config.dashboard.enabled is True
|
||||
assert config.api.enabled is False
|
||||
|
||||
|
||||
def test_load_config_with_defaults_only() -> None:
|
||||
"""Test loading config without a file uses defaults."""
|
||||
config = load_config(None)
|
||||
|
||||
assert config.instruments.backend == "simulator"
|
||||
assert config.physics.update_rate_hz == 100.0
|
||||
|
||||
|
||||
def test_load_config_from_file(tmp_path: Path) -> None:
|
||||
"""Test loading configuration from YAML file."""
|
||||
config_file = tmp_path / "test_config.yaml"
|
||||
config_data = {
|
||||
"instruments": {"backend": "pyvisa"},
|
||||
"physics": {"update_rate_hz": 50.0},
|
||||
"dut": {"model": "custom_ldo"},
|
||||
}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.instruments.backend == "pyvisa"
|
||||
assert config.physics.update_rate_hz == 50.0
|
||||
assert config.dut.model == "custom_ldo"
|
||||
# Defaults still apply
|
||||
assert config.instruments.simulator.host == "localhost"
|
||||
|
||||
|
||||
def test_load_config_partial_override(tmp_path: Path) -> None:
|
||||
"""Test that partial config overrides work correctly."""
|
||||
config_file = tmp_path / "partial.yaml"
|
||||
config_data = {
|
||||
"physics": {
|
||||
"thermal": {
|
||||
"theta_jc": 20.0,
|
||||
# Other thermal params should use defaults
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Overridden value
|
||||
assert config.physics.thermal.theta_jc == 20.0
|
||||
# Default values
|
||||
assert config.physics.thermal.theta_ca == 5.0
|
||||
assert config.physics.thermal.chamber_time_constant_s == 30.0
|
||||
|
||||
|
||||
def test_load_config_missing_file() -> None:
|
||||
"""Test that loading from missing file raises FileNotFoundError."""
|
||||
with pytest.raises(FileNotFoundError, match="Configuration file not found"):
|
||||
load_config("nonexistent.yaml")
|
||||
|
||||
|
||||
def test_load_config_invalid_yaml(tmp_path: Path) -> None:
|
||||
"""Test that malformed YAML raises an error."""
|
||||
config_file = tmp_path / "invalid.yaml"
|
||||
config_file.write_text("invalid: yaml: content: [\n")
|
||||
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_load_config_validation_error(tmp_path: Path) -> None:
|
||||
"""Test that invalid configuration raises ValidationError."""
|
||||
config_file = tmp_path / "invalid_config.yaml"
|
||||
config_data = {
|
||||
"instruments": {"backend": "invalid_backend"}, # Not in Literal["simulator", "pyvisa"]
|
||||
}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
load_config(config_file)
|
||||
|
||||
|
||||
def test_env_override_simple(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test environment variable override for simple values."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data: dict[str, Any] = {}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
monkeypatch.setenv("PYDVTATE__INSTRUMENTS__BACKEND", "pyvisa")
|
||||
monkeypatch.setenv("PYDVTATE__PHYSICS__UPDATE_RATE_HZ", "200.0")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.instruments.backend == "pyvisa"
|
||||
assert config.physics.update_rate_hz == 200.0
|
||||
|
||||
|
||||
def test_env_override_nested(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test environment variable override for nested values."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data: dict[str, Any] = {}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
monkeypatch.setenv("PYDVTATE__INSTRUMENTS__SIMULATOR__HOST", "192.168.1.100")
|
||||
monkeypatch.setenv("PYDVTATE__INSTRUMENTS__SIMULATOR__THERMAL_CHAMBER_PORT", "6001")
|
||||
monkeypatch.setenv("PYDVTATE__PHYSICS__THERMAL__THETA_JC", "25.0")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.instruments.simulator.host == "192.168.1.100"
|
||||
assert config.instruments.simulator.thermal_chamber_port == 6001
|
||||
assert config.physics.thermal.theta_jc == 25.0
|
||||
|
||||
|
||||
def test_env_override_types(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that environment variables are parsed to correct types."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data: dict[str, Any] = {}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
monkeypatch.setenv("PYDVTATE__DASHBOARD__ENABLED", "false") # bool
|
||||
monkeypatch.setenv("PYDVTATE__DASHBOARD__PORT", "9000") # int
|
||||
monkeypatch.setenv("PYDVTATE__PHYSICS__UPDATE_RATE_HZ", "75.5") # float
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.dashboard.enabled is False
|
||||
assert config.dashboard.port == 9000
|
||||
assert config.physics.update_rate_hz == 75.5
|
||||
|
||||
|
||||
def test_env_override_precedence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that environment variables override file values."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data = {"physics": {"update_rate_hz": 50.0}}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
monkeypatch.setenv("PYDVTATE__PHYSICS__UPDATE_RATE_HZ", "150.0")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Environment variable should win
|
||||
assert config.physics.update_rate_hz == 150.0
|
||||
|
||||
|
||||
def test_env_variables_ignored_without_prefix(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Test that environment variables without prefix are ignored."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data: dict[str, Any] = {}
|
||||
|
||||
with config_file.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# These should be ignored
|
||||
monkeypatch.setenv("BACKEND", "pyvisa")
|
||||
monkeypatch.setenv("UPDATE_RATE_HZ", "200.0")
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Should use defaults
|
||||
assert config.instruments.backend == "simulator"
|
||||
assert config.physics.update_rate_hz == 100.0
|
||||
|
||||
|
||||
def test_simulator_config_defaults() -> None:
|
||||
"""Test SimulatorConfig default values."""
|
||||
config = SimulatorConfig()
|
||||
assert config.host == "localhost"
|
||||
assert config.thermal_chamber_port == 5001
|
||||
assert config.power_supply_port == 5002
|
||||
assert config.multimeter_port == 5003
|
||||
|
||||
|
||||
def test_pyvisa_config_defaults() -> None:
|
||||
"""Test PyVISAConfig default values."""
|
||||
config = PyVISAConfig()
|
||||
assert config.thermal_chamber is None
|
||||
assert config.power_supply is None
|
||||
assert config.multimeter is None
|
||||
|
||||
|
||||
def test_complete_config_structure() -> None:
|
||||
"""Test that all config sections can be instantiated."""
|
||||
config = AppConfig(
|
||||
instruments=InstrumentsConfig(
|
||||
backend="pyvisa",
|
||||
simulator=SimulatorConfig(host="192.168.1.1"),
|
||||
pyvisa=PyVISAConfig(thermal_chamber="TCPIP::192.168.1.10::INSTR"),
|
||||
),
|
||||
physics=PhysicsConfig(
|
||||
update_rate_hz=50.0,
|
||||
thermal=ThermalConfig(theta_jc=20.0),
|
||||
chamber=ChamberConfig(ramp_rate_c_per_min=5.0),
|
||||
),
|
||||
dut=DUTConfig(
|
||||
model="custom", parameters=DUTParameters(nominal_output_voltage=5.0)
|
||||
),
|
||||
data=DataConfig(database_path="/tmp/test.db"),
|
||||
logging=LoggingConfig(level="DEBUG"),
|
||||
dashboard=DashboardConfig(enabled=False),
|
||||
api=APIConfig(enabled=True, port=9000),
|
||||
)
|
||||
|
||||
assert config.instruments.backend == "pyvisa"
|
||||
assert config.physics.update_rate_hz == 50.0
|
||||
assert config.dut.parameters.nominal_output_voltage == 5.0
|
||||
assert config.api.port == 9000
|
||||
346
tests/unit/test_drivers.py
Normal file
346
tests/unit/test_drivers.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Unit tests for instrument drivers.
|
||||
|
||||
Tests SCPI command formatting and driver functionality using mock transports.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.instruments.drivers.base import BaseDriver
|
||||
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
|
||||
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
|
||||
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_transport():
|
||||
"""Create a mock transport for testing."""
|
||||
transport = MagicMock()
|
||||
transport.is_connected = True
|
||||
return transport
|
||||
|
||||
|
||||
class TestBaseDriver:
|
||||
"""Tests for BaseDriver base class."""
|
||||
|
||||
def test_connect(self, mock_transport):
|
||||
"""Test connection establishment."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
driver.connect()
|
||||
mock_transport.connect.assert_called_once()
|
||||
|
||||
def test_disconnect(self, mock_transport):
|
||||
"""Test disconnection."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
driver.disconnect()
|
||||
mock_transport.disconnect.assert_called_once()
|
||||
|
||||
def test_is_connected(self, mock_transport):
|
||||
"""Test connection status check."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
assert driver.is_connected is True
|
||||
|
||||
def test_write(self, mock_transport):
|
||||
"""Test SCPI command write."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
driver.write("VOLT 3.3")
|
||||
mock_transport.write.assert_called_once_with("VOLT 3.3")
|
||||
|
||||
def test_query(self, mock_transport):
|
||||
"""Test SCPI query."""
|
||||
mock_transport.query.return_value = "3.300"
|
||||
driver = BaseDriver(mock_transport)
|
||||
result = driver.query("VOLT?")
|
||||
assert result == "3.300"
|
||||
mock_transport.query.assert_called_once_with("VOLT?", None)
|
||||
|
||||
def test_query_float(self, mock_transport):
|
||||
"""Test SCPI query with float parsing."""
|
||||
mock_transport.query.return_value = "3.300"
|
||||
driver = BaseDriver(mock_transport)
|
||||
result = driver.query_float("VOLT?")
|
||||
assert result == 3.3
|
||||
assert isinstance(result, float)
|
||||
|
||||
def test_query_float_invalid(self, mock_transport):
|
||||
"""Test SCPI query with invalid float response."""
|
||||
mock_transport.query.return_value = "INVALID"
|
||||
driver = BaseDriver(mock_transport)
|
||||
with pytest.raises(ValueError, match="Cannot parse 'INVALID' as float"):
|
||||
driver.query_float("VOLT?")
|
||||
|
||||
def test_query_int(self, mock_transport):
|
||||
"""Test SCPI query with integer parsing."""
|
||||
mock_transport.query.return_value = "42"
|
||||
driver = BaseDriver(mock_transport)
|
||||
result = driver.query_int("COUNT?")
|
||||
assert result == 42
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_query_int_invalid(self, mock_transport):
|
||||
"""Test SCPI query with invalid integer response."""
|
||||
mock_transport.query.return_value = "3.14"
|
||||
driver = BaseDriver(mock_transport)
|
||||
with pytest.raises(ValueError, match="Cannot parse '3.14' as int"):
|
||||
driver.query_int("COUNT?")
|
||||
|
||||
def test_query_bool_true_variants(self, mock_transport):
|
||||
"""Test SCPI query with boolean parsing - true variants."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
|
||||
for value in ["1", "ON", "TRUE", "on", "true"]:
|
||||
mock_transport.query.return_value = value
|
||||
result = driver.query_bool("OUTP?")
|
||||
assert result is True
|
||||
|
||||
def test_query_bool_false_variants(self, mock_transport):
|
||||
"""Test SCPI query with boolean parsing - false variants."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
|
||||
for value in ["0", "OFF", "FALSE", "off", "false"]:
|
||||
mock_transport.query.return_value = value
|
||||
result = driver.query_bool("OUTP?")
|
||||
assert result is False
|
||||
|
||||
def test_query_bool_invalid(self, mock_transport):
|
||||
"""Test SCPI query with invalid boolean response."""
|
||||
mock_transport.query.return_value = "MAYBE"
|
||||
driver = BaseDriver(mock_transport)
|
||||
with pytest.raises(ValueError, match="Cannot parse 'MAYBE' as bool"):
|
||||
driver.query_bool("OUTP?")
|
||||
|
||||
def test_identify(self, mock_transport):
|
||||
"""Test instrument identification query."""
|
||||
mock_transport.query.return_value = "Manufacturer,Model,SN123,1.0.0"
|
||||
driver = BaseDriver(mock_transport)
|
||||
result = driver.identify()
|
||||
assert result == "Manufacturer,Model,SN123,1.0.0"
|
||||
mock_transport.query.assert_called_once_with("*IDN?", None)
|
||||
|
||||
def test_reset(self, mock_transport):
|
||||
"""Test instrument reset command."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
driver.reset()
|
||||
mock_transport.write.assert_called_once_with("*RST")
|
||||
|
||||
def test_clear_status(self, mock_transport):
|
||||
"""Test clear status command."""
|
||||
driver = BaseDriver(mock_transport)
|
||||
driver.clear_status()
|
||||
mock_transport.write.assert_called_once_with("*CLS")
|
||||
|
||||
def test_operation_complete(self, mock_transport):
|
||||
"""Test operation complete query."""
|
||||
mock_transport.query.return_value = "1"
|
||||
driver = BaseDriver(mock_transport)
|
||||
result = driver.operation_complete()
|
||||
assert result is True
|
||||
mock_transport.query.assert_called_once_with("*OPC?", None)
|
||||
|
||||
|
||||
class TestThermalChamberDriver:
|
||||
"""Tests for ThermalChamberDriver."""
|
||||
|
||||
def test_set_temperature(self, mock_transport):
|
||||
"""Test temperature setpoint command."""
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
driver.set_temperature(85.0)
|
||||
mock_transport.write.assert_called_once_with("TEMP:SETPOINT 85.00")
|
||||
|
||||
def test_get_temperature(self, mock_transport):
|
||||
"""Test temperature measurement query."""
|
||||
mock_transport.query.return_value = "25.50"
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
temp = driver.get_temperature()
|
||||
assert temp == 25.5
|
||||
mock_transport.query.assert_called_once_with("TEMP:ACTUAL?", None)
|
||||
|
||||
def test_get_setpoint(self, mock_transport):
|
||||
"""Test setpoint query."""
|
||||
mock_transport.query.return_value = "85.00"
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
setpoint = driver.get_setpoint()
|
||||
assert setpoint == 85.0
|
||||
mock_transport.query.assert_called_once_with("TEMP:SETPOINT?", None)
|
||||
|
||||
def test_is_stable_true(self, mock_transport):
|
||||
"""Test stability check - stable."""
|
||||
mock_transport.query.return_value = "1"
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
assert driver.is_stable() is True
|
||||
|
||||
def test_is_stable_false(self, mock_transport):
|
||||
"""Test stability check - not stable."""
|
||||
mock_transport.query.return_value = "0"
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
assert driver.is_stable() is False
|
||||
|
||||
def test_wait_until_stable_immediate(self, mock_transport):
|
||||
"""Test wait for stability - already stable."""
|
||||
mock_transport.query.return_value = "1"
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
result = driver.wait_until_stable(timeout=5.0, poll_interval=0.1)
|
||||
assert result is True
|
||||
|
||||
def test_wait_until_stable_timeout(self, mock_transport):
|
||||
"""Test wait for stability - timeout."""
|
||||
mock_transport.query.return_value = "0" # Never becomes stable
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
result = driver.wait_until_stable(timeout=0.2, poll_interval=0.1)
|
||||
assert result is False
|
||||
|
||||
def test_wait_until_stable_invalid_timeout(self, mock_transport):
|
||||
"""Test wait with negative timeout."""
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
with pytest.raises(ValueError, match="Timeout must be non-negative"):
|
||||
driver.wait_until_stable(timeout=-1.0)
|
||||
|
||||
def test_wait_until_stable_invalid_interval(self, mock_transport):
|
||||
"""Test wait with non-positive poll interval."""
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
with pytest.raises(ValueError, match="Poll interval must be positive"):
|
||||
driver.wait_until_stable(poll_interval=0.0)
|
||||
|
||||
def test_set_ramp_rate(self, mock_transport):
|
||||
"""Test ramp rate command."""
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
driver.set_ramp_rate(5.0)
|
||||
mock_transport.write.assert_called_once_with("TEMP:RAMP 5.00")
|
||||
|
||||
def test_get_ramp_rate(self, mock_transport):
|
||||
"""Test ramp rate query."""
|
||||
mock_transport.query.return_value = "5.00"
|
||||
driver = ThermalChamberDriver(mock_transport)
|
||||
rate = driver.get_ramp_rate()
|
||||
assert rate == 5.0
|
||||
|
||||
|
||||
class TestPowerSupplyDriver:
|
||||
"""Tests for PowerSupplyDriver."""
|
||||
|
||||
def test_set_voltage(self, mock_transport):
|
||||
"""Test voltage setpoint command."""
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
driver.set_voltage(1, 3.3)
|
||||
mock_transport.write.assert_called_once_with("VOLT 3.300")
|
||||
|
||||
def test_get_voltage(self, mock_transport):
|
||||
"""Test voltage setpoint query."""
|
||||
mock_transport.query.return_value = "3.300"
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
voltage = driver.get_voltage(1)
|
||||
assert voltage == 3.3
|
||||
|
||||
def test_set_current_limit(self, mock_transport):
|
||||
"""Test current limit command."""
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
driver.set_current_limit(1, 0.5)
|
||||
mock_transport.write.assert_called_once_with("CURR 0.500")
|
||||
|
||||
def test_get_current_limit(self, mock_transport):
|
||||
"""Test current limit query."""
|
||||
mock_transport.query.return_value = "0.500"
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
current = driver.get_current_limit(1)
|
||||
assert current == 0.5
|
||||
|
||||
def test_measure_voltage(self, mock_transport):
|
||||
"""Test voltage measurement."""
|
||||
mock_transport.query.return_value = "3.305"
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
voltage = driver.measure_voltage(1)
|
||||
assert voltage == 3.305
|
||||
mock_transport.query.assert_called_once_with("MEAS:VOLT?", None)
|
||||
|
||||
def test_measure_current(self, mock_transport):
|
||||
"""Test current measurement."""
|
||||
mock_transport.query.return_value = "0.125"
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
current = driver.measure_current(1)
|
||||
assert current == 0.125
|
||||
mock_transport.query.assert_called_once_with("MEAS:CURR?", None)
|
||||
|
||||
def test_enable_output_on(self, mock_transport):
|
||||
"""Test enable output command."""
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
driver.enable_output(1, True)
|
||||
mock_transport.write.assert_called_once_with("OUTP ON")
|
||||
|
||||
def test_enable_output_off(self, mock_transport):
|
||||
"""Test disable output command."""
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
driver.enable_output(1, False)
|
||||
mock_transport.write.assert_called_once_with("OUTP OFF")
|
||||
|
||||
def test_is_output_enabled_true(self, mock_transport):
|
||||
"""Test output enabled query - enabled."""
|
||||
mock_transport.query.return_value = "1"
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
assert driver.is_output_enabled(1) is True
|
||||
|
||||
def test_is_output_enabled_false(self, mock_transport):
|
||||
"""Test output enabled query - disabled."""
|
||||
mock_transport.query.return_value = "0"
|
||||
driver = PowerSupplyDriver(mock_transport)
|
||||
assert driver.is_output_enabled(1) is False
|
||||
|
||||
|
||||
class TestMultimeterDriver:
|
||||
"""Tests for MultimeterDriver."""
|
||||
|
||||
def test_measure_dc_voltage(self, mock_transport):
|
||||
"""Test DC voltage measurement."""
|
||||
mock_transport.query.return_value = "3.300000"
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
voltage = driver.measure_dc_voltage()
|
||||
assert voltage == 3.3
|
||||
mock_transport.query.assert_called_once_with("MEAS:VOLT:DC?", None)
|
||||
|
||||
def test_measure_dc_current(self, mock_transport):
|
||||
"""Test DC current measurement."""
|
||||
mock_transport.query.return_value = "0.125000"
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
current = driver.measure_dc_current()
|
||||
assert current == 0.125
|
||||
mock_transport.query.assert_called_once_with("MEAS:CURR:DC?", None)
|
||||
|
||||
def test_measure_resistance_not_implemented(self, mock_transport):
|
||||
"""Test resistance measurement raises NotImplementedError."""
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
with pytest.raises(NotImplementedError, match="Resistance measurement"):
|
||||
driver.measure_resistance()
|
||||
|
||||
def test_set_integration_time_not_implemented(self, mock_transport):
|
||||
"""Test integration time setting raises NotImplementedError."""
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
with pytest.raises(NotImplementedError, match="Integration time"):
|
||||
driver.set_integration_time(1.0)
|
||||
|
||||
def test_configure_dc_voltage(self, mock_transport):
|
||||
"""Test configure for DC voltage."""
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
driver.configure_dc_voltage()
|
||||
mock_transport.write.assert_called_once_with("CONF:VOLT:DC")
|
||||
|
||||
def test_configure_dc_current(self, mock_transport):
|
||||
"""Test configure for DC current."""
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
driver.configure_dc_current()
|
||||
mock_transport.write.assert_called_once_with("CONF:CURR:DC")
|
||||
|
||||
def test_get_configuration(self, mock_transport):
|
||||
"""Test get current configuration."""
|
||||
mock_transport.query.return_value = '"VOLT:DC"'
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
config = driver.get_configuration()
|
||||
assert config == "VOLT:DC"
|
||||
mock_transport.query.assert_called_once_with("CONF?", None)
|
||||
|
||||
def test_read(self, mock_transport):
|
||||
"""Test read measurement with current configuration."""
|
||||
mock_transport.query.return_value = "3.300000"
|
||||
driver = MultimeterDriver(mock_transport)
|
||||
value = driver.read()
|
||||
assert value == 3.3
|
||||
mock_transport.query.assert_called_once_with("READ?", None)
|
||||
273
tests/unit/test_instruments.py
Normal file
273
tests/unit/test_instruments.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Unit tests for instrument interfaces and factory.
|
||||
|
||||
Tests the Hardware Abstraction Layer (HAL) interfaces and the factory
|
||||
pattern for creating instrument sets.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.instruments import (
|
||||
IMultimeter,
|
||||
InstrumentConfig,
|
||||
InstrumentFactory,
|
||||
InstrumentSet,
|
||||
IPowerSupply,
|
||||
IThermalChamber,
|
||||
)
|
||||
from py_dvt_ate.instruments.drivers import (
|
||||
MultimeterDriver,
|
||||
PowerSupplyDriver,
|
||||
ThermalChamberDriver,
|
||||
)
|
||||
|
||||
|
||||
class TestInterfaceImplementation:
|
||||
"""Test that drivers correctly implement the interface protocols."""
|
||||
|
||||
def test_thermal_chamber_implements_interface(self):
|
||||
"""Verify ThermalChamberDriver implements IThermalChamber."""
|
||||
# ABC inheritance ensures interface compliance at class definition time
|
||||
assert issubclass(ThermalChamberDriver, IThermalChamber)
|
||||
|
||||
def test_power_supply_implements_interface(self):
|
||||
"""Verify PowerSupplyDriver implements IPowerSupply."""
|
||||
assert issubclass(PowerSupplyDriver, IPowerSupply)
|
||||
|
||||
def test_multimeter_implements_interface(self):
|
||||
"""Verify MultimeterDriver implements IMultimeter."""
|
||||
assert issubclass(MultimeterDriver, IMultimeter)
|
||||
|
||||
def test_thermal_chamber_has_all_methods(self):
|
||||
"""Verify ThermalChamberDriver has all required methods."""
|
||||
required_methods = [
|
||||
"set_temperature",
|
||||
"get_temperature",
|
||||
"get_setpoint",
|
||||
"is_stable",
|
||||
"wait_until_stable",
|
||||
"set_ramp_rate",
|
||||
]
|
||||
for method in required_methods:
|
||||
assert hasattr(ThermalChamberDriver, method)
|
||||
|
||||
def test_power_supply_has_all_methods(self):
|
||||
"""Verify PowerSupplyDriver has all required methods."""
|
||||
required_methods = [
|
||||
"set_voltage",
|
||||
"get_voltage",
|
||||
"set_current_limit",
|
||||
"get_current_limit",
|
||||
"measure_voltage",
|
||||
"measure_current",
|
||||
"enable_output",
|
||||
"is_output_enabled",
|
||||
]
|
||||
for method in required_methods:
|
||||
assert hasattr(PowerSupplyDriver, method)
|
||||
|
||||
def test_multimeter_has_all_methods(self):
|
||||
"""Verify MultimeterDriver has all required methods."""
|
||||
required_methods = [
|
||||
"measure_dc_voltage",
|
||||
"measure_dc_current",
|
||||
"measure_resistance",
|
||||
"set_integration_time",
|
||||
]
|
||||
for method in required_methods:
|
||||
assert hasattr(MultimeterDriver, method)
|
||||
|
||||
|
||||
class TestInstrumentSet:
|
||||
"""Test the InstrumentSet dataclass."""
|
||||
|
||||
def test_instrument_set_creation(self):
|
||||
"""Verify InstrumentSet can be created with mock instruments."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
# Create mock instruments that satisfy the interface
|
||||
mock_chamber = Mock(spec=IThermalChamber)
|
||||
mock_psu = Mock(spec=IPowerSupply)
|
||||
mock_dmm = Mock(spec=IMultimeter)
|
||||
|
||||
instrument_set = InstrumentSet(
|
||||
chamber=mock_chamber, psu=mock_psu, dmm=mock_dmm
|
||||
)
|
||||
|
||||
assert instrument_set.chamber is mock_chamber
|
||||
assert instrument_set.psu is mock_psu
|
||||
assert instrument_set.dmm is mock_dmm
|
||||
|
||||
def test_instrument_set_type_annotations(self):
|
||||
"""Verify InstrumentSet has correct type annotations."""
|
||||
annotations = InstrumentSet.__annotations__
|
||||
|
||||
assert annotations["chamber"] == IThermalChamber
|
||||
assert annotations["psu"] == IPowerSupply
|
||||
assert annotations["dmm"] == IMultimeter
|
||||
|
||||
|
||||
class TestInstrumentConfig:
|
||||
"""Test the InstrumentConfig dataclass."""
|
||||
|
||||
def test_config_defaults_simulator(self):
|
||||
"""Verify default configuration for simulator backend."""
|
||||
config = InstrumentConfig(backend="simulator")
|
||||
|
||||
assert config.backend == "simulator"
|
||||
assert config.simulator_host == "localhost"
|
||||
assert config.chamber_port == 5001
|
||||
assert config.psu_port == 5002
|
||||
assert config.dmm_port == 5003
|
||||
assert config.chamber_visa is None
|
||||
assert config.psu_visa is None
|
||||
assert config.dmm_visa is None
|
||||
|
||||
def test_config_custom_ports(self):
|
||||
"""Verify configuration accepts custom port settings."""
|
||||
config = InstrumentConfig(
|
||||
backend="simulator",
|
||||
simulator_host="192.168.1.100",
|
||||
chamber_port=6001,
|
||||
psu_port=6002,
|
||||
dmm_port=6003,
|
||||
)
|
||||
|
||||
assert config.simulator_host == "192.168.1.100"
|
||||
assert config.chamber_port == 6001
|
||||
assert config.psu_port == 6002
|
||||
assert config.dmm_port == 6003
|
||||
|
||||
def test_config_pyvisa_backend(self):
|
||||
"""Verify configuration for PyVISA backend."""
|
||||
config = InstrumentConfig(
|
||||
backend="pyvisa",
|
||||
chamber_visa="TCPIP::192.168.1.10::INSTR",
|
||||
psu_visa="TCPIP::192.168.1.11::INSTR",
|
||||
dmm_visa="TCPIP::192.168.1.12::INSTR",
|
||||
)
|
||||
|
||||
assert config.backend == "pyvisa"
|
||||
assert config.chamber_visa == "TCPIP::192.168.1.10::INSTR"
|
||||
assert config.psu_visa == "TCPIP::192.168.1.11::INSTR"
|
||||
assert config.dmm_visa == "TCPIP::192.168.1.12::INSTR"
|
||||
|
||||
|
||||
class TestInstrumentFactory:
|
||||
"""Test the InstrumentFactory."""
|
||||
|
||||
def test_factory_rejects_unknown_backend(self):
|
||||
"""Verify factory raises error for unknown backend."""
|
||||
config = InstrumentConfig(backend="invalid") # type: ignore
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown backend: invalid"):
|
||||
InstrumentFactory.create(config)
|
||||
|
||||
def test_factory_pyvisa_not_implemented(self):
|
||||
"""Verify PyVISA backend raises NotImplementedError."""
|
||||
config = InstrumentConfig(backend="pyvisa")
|
||||
|
||||
with pytest.raises(NotImplementedError, match="PyVISA backend not yet"):
|
||||
InstrumentFactory.create(config)
|
||||
|
||||
def test_factory_creates_instrument_set(self):
|
||||
"""Verify factory creates InstrumentSet with correct structure."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
config = InstrumentConfig(backend="simulator")
|
||||
|
||||
# Mock the transports and drivers to avoid actual connections
|
||||
# Patch where they're imported FROM, not where they're used
|
||||
with (
|
||||
patch(
|
||||
"py_dvt_ate.instruments.transport.tcp.TCPTransport"
|
||||
) as mock_tcp_transport,
|
||||
patch(
|
||||
"py_dvt_ate.instruments.drivers.chamber.ThermalChamberDriver"
|
||||
) as mock_chamber,
|
||||
patch(
|
||||
"py_dvt_ate.instruments.drivers.power_supply.PowerSupplyDriver"
|
||||
) as mock_psu,
|
||||
patch(
|
||||
"py_dvt_ate.instruments.drivers.multimeter.MultimeterDriver"
|
||||
) as mock_dmm,
|
||||
):
|
||||
# Create mock instrument instances
|
||||
mock_chamber_instance = Mock(spec=IThermalChamber)
|
||||
mock_psu_instance = Mock(spec=IPowerSupply)
|
||||
mock_dmm_instance = Mock(spec=IMultimeter)
|
||||
|
||||
mock_chamber.return_value = mock_chamber_instance
|
||||
mock_psu.return_value = mock_psu_instance
|
||||
mock_dmm.return_value = mock_dmm_instance
|
||||
|
||||
instrument_set = InstrumentFactory.create(config)
|
||||
|
||||
# Verify InstrumentSet was created
|
||||
assert isinstance(instrument_set, InstrumentSet)
|
||||
|
||||
# Verify transports were created with correct parameters
|
||||
assert mock_tcp_transport.call_count == 3
|
||||
mock_tcp_transport.assert_any_call("localhost", 5001) # chamber
|
||||
mock_tcp_transport.assert_any_call("localhost", 5002) # psu
|
||||
mock_tcp_transport.assert_any_call("localhost", 5003) # dmm
|
||||
|
||||
# Verify drivers were created
|
||||
assert mock_chamber.call_count == 1
|
||||
assert mock_psu.call_count == 1
|
||||
assert mock_dmm.call_count == 1
|
||||
|
||||
# Verify InstrumentSet contains the mock instances
|
||||
assert instrument_set.chamber is mock_chamber_instance
|
||||
assert instrument_set.psu is mock_psu_instance
|
||||
assert instrument_set.dmm is mock_dmm_instance
|
||||
|
||||
def test_factory_uses_custom_ports(self):
|
||||
"""Verify factory uses custom port configuration."""
|
||||
from unittest.mock import patch
|
||||
|
||||
config = InstrumentConfig(
|
||||
backend="simulator",
|
||||
simulator_host="testserver",
|
||||
chamber_port=7001,
|
||||
psu_port=7002,
|
||||
dmm_port=7003,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"py_dvt_ate.instruments.transport.tcp.TCPTransport"
|
||||
) as mock_tcp_transport:
|
||||
InstrumentFactory.create(config)
|
||||
|
||||
# Verify custom host and ports were used
|
||||
mock_tcp_transport.assert_any_call("testserver", 7001)
|
||||
mock_tcp_transport.assert_any_call("testserver", 7002)
|
||||
mock_tcp_transport.assert_any_call("testserver", 7003)
|
||||
|
||||
def test_factory_returns_correct_types(self):
|
||||
"""Verify factory returns instruments implementing correct interfaces."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
config = InstrumentConfig(backend="simulator")
|
||||
|
||||
with (
|
||||
patch("py_dvt_ate.instruments.transport.tcp.TCPTransport"),
|
||||
patch(
|
||||
"py_dvt_ate.instruments.drivers.chamber.ThermalChamberDriver"
|
||||
) as mock_chamber,
|
||||
patch(
|
||||
"py_dvt_ate.instruments.drivers.power_supply.PowerSupplyDriver"
|
||||
) as mock_psu,
|
||||
patch(
|
||||
"py_dvt_ate.instruments.drivers.multimeter.MultimeterDriver"
|
||||
) as mock_dmm,
|
||||
):
|
||||
# Make the mocks subclasses of the interfaces
|
||||
mock_chamber.return_value = Mock(spec=IThermalChamber)
|
||||
mock_psu.return_value = Mock(spec=IPowerSupply)
|
||||
mock_dmm.return_value = Mock(spec=IMultimeter)
|
||||
|
||||
instrument_set = InstrumentFactory.create(config)
|
||||
|
||||
# Verify returned instruments satisfy the interface specs
|
||||
# (Mock with spec=Interface makes isinstance checks work)
|
||||
assert isinstance(instrument_set, InstrumentSet)
|
||||
306
tests/unit/test_multimeter.py
Normal file
306
tests/unit/test_multimeter.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Unit tests for multimeter simulator."""
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
from py_dvt_ate.simulation.virtual.multimeter import MultimeterSim
|
||||
|
||||
|
||||
class TestMultimeterSimBasic:
|
||||
"""Tests for MultimeterSim without physics engine."""
|
||||
|
||||
@pytest.fixture
|
||||
def dmm(self) -> MultimeterSim:
|
||||
"""Create multimeter instance without physics engine."""
|
||||
return MultimeterSim()
|
||||
|
||||
def test_creation(self, dmm: MultimeterSim) -> None:
|
||||
"""Test multimeter can be created."""
|
||||
assert dmm is not None
|
||||
assert dmm.model == "DMM-SIM-001"
|
||||
assert dmm.manufacturer == "PyDVTATE"
|
||||
|
||||
def test_idn_query(self, dmm: MultimeterSim) -> None:
|
||||
"""Test *IDN? returns identification string."""
|
||||
response = dmm.process("*IDN?")
|
||||
|
||||
assert "PyDVTATE" in response
|
||||
assert "DMM-SIM-001" in response
|
||||
|
||||
def test_rst_command(self, dmm: MultimeterSim) -> None:
|
||||
"""Test *RST resets to defaults."""
|
||||
# Set non-default config
|
||||
dmm.process("CONF:CURR:DC")
|
||||
assert dmm.process("CONF?") == '"CURR:DC"'
|
||||
|
||||
# Reset
|
||||
response = dmm.process("*RST")
|
||||
assert response == ""
|
||||
|
||||
# Check defaults restored
|
||||
assert dmm.process("CONF?") == '"VOLT:DC"'
|
||||
|
||||
def test_opc_query(self, dmm: MultimeterSim) -> None:
|
||||
"""Test *OPC? returns 1."""
|
||||
response = dmm.process("*OPC?")
|
||||
assert response == "1"
|
||||
|
||||
def test_unknown_command(self, dmm: MultimeterSim) -> None:
|
||||
"""Test unknown command returns error."""
|
||||
response = dmm.process("INVALID:CMD")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Unknown command" in response
|
||||
|
||||
|
||||
class TestMultimeterMeasVoltDC:
|
||||
"""Tests for MEAS:VOLT:DC command."""
|
||||
|
||||
@pytest.fixture
|
||||
def dmm(self) -> MultimeterSim:
|
||||
"""Create multimeter instance without physics engine."""
|
||||
return MultimeterSim()
|
||||
|
||||
def test_meas_volt_dc_query_no_engine(self, dmm: MultimeterSim) -> None:
|
||||
"""Test MEAS:VOLT:DC? returns 0 without physics engine."""
|
||||
response = dmm.process("MEAS:VOLT:DC?")
|
||||
|
||||
assert response == "0.000000"
|
||||
|
||||
def test_meas_volt_dc_sets_config(self, dmm: MultimeterSim) -> None:
|
||||
"""Test MEAS:VOLT:DC? sets configuration to VOLT:DC."""
|
||||
dmm.process("CONF:CURR:DC")
|
||||
dmm.process("MEAS:VOLT:DC?")
|
||||
|
||||
assert dmm.process("CONF?") == '"VOLT:DC"'
|
||||
|
||||
def test_meas_volt_dc_as_command_fails(self, dmm: MultimeterSim) -> None:
|
||||
"""Test MEAS:VOLT:DC (without ?) returns error."""
|
||||
response = dmm.process("MEAS:VOLT:DC 1.0")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
|
||||
class TestMultimeterMeasCurrDC:
|
||||
"""Tests for MEAS:CURR:DC command."""
|
||||
|
||||
@pytest.fixture
|
||||
def dmm(self) -> MultimeterSim:
|
||||
"""Create multimeter instance without physics engine."""
|
||||
return MultimeterSim()
|
||||
|
||||
def test_meas_curr_dc_query_no_engine(self, dmm: MultimeterSim) -> None:
|
||||
"""Test MEAS:CURR:DC? returns 0 without physics engine."""
|
||||
response = dmm.process("MEAS:CURR:DC?")
|
||||
|
||||
assert response == "0.000000"
|
||||
|
||||
def test_meas_curr_dc_sets_config(self, dmm: MultimeterSim) -> None:
|
||||
"""Test MEAS:CURR:DC? sets configuration to CURR:DC."""
|
||||
dmm.process("MEAS:CURR:DC?")
|
||||
|
||||
assert dmm.process("CONF?") == '"CURR:DC"'
|
||||
|
||||
def test_meas_curr_dc_as_command_fails(self, dmm: MultimeterSim) -> None:
|
||||
"""Test MEAS:CURR:DC (without ?) returns error."""
|
||||
response = dmm.process("MEAS:CURR:DC 0.1")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
|
||||
class TestMultimeterConf:
|
||||
"""Tests for CONF commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def dmm(self) -> MultimeterSim:
|
||||
"""Create multimeter instance without physics engine."""
|
||||
return MultimeterSim()
|
||||
|
||||
def test_conf_query_default(self, dmm: MultimeterSim) -> None:
|
||||
"""Test CONF? returns default configuration."""
|
||||
response = dmm.process("CONF?")
|
||||
|
||||
assert response == '"VOLT:DC"'
|
||||
|
||||
def test_conf_volt_dc(self, dmm: MultimeterSim) -> None:
|
||||
"""Test CONF:VOLT:DC sets voltage measurement mode."""
|
||||
dmm.process("CONF:CURR:DC")
|
||||
response = dmm.process("CONF:VOLT:DC")
|
||||
|
||||
assert response == ""
|
||||
assert dmm.process("CONF?") == '"VOLT:DC"'
|
||||
|
||||
def test_conf_volt_dc_as_query_fails(self, dmm: MultimeterSim) -> None:
|
||||
"""Test CONF:VOLT:DC? returns error."""
|
||||
response = dmm.process("CONF:VOLT:DC?")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "command only" in response
|
||||
|
||||
def test_conf_curr_dc(self, dmm: MultimeterSim) -> None:
|
||||
"""Test CONF:CURR:DC sets current measurement mode."""
|
||||
response = dmm.process("CONF:CURR:DC")
|
||||
|
||||
assert response == ""
|
||||
assert dmm.process("CONF?") == '"CURR:DC"'
|
||||
|
||||
def test_conf_curr_dc_as_query_fails(self, dmm: MultimeterSim) -> None:
|
||||
"""Test CONF:CURR:DC? returns error."""
|
||||
response = dmm.process("CONF:CURR:DC?")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "command only" in response
|
||||
|
||||
def test_conf_as_command_fails(self, dmm: MultimeterSim) -> None:
|
||||
"""Test CONF without subcommand returns error."""
|
||||
response = dmm.process("CONF")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
|
||||
|
||||
class TestMultimeterRead:
|
||||
"""Tests for READ command."""
|
||||
|
||||
@pytest.fixture
|
||||
def dmm(self) -> MultimeterSim:
|
||||
"""Create multimeter instance without physics engine."""
|
||||
return MultimeterSim()
|
||||
|
||||
def test_read_query_volt_mode(self, dmm: MultimeterSim) -> None:
|
||||
"""Test READ? returns voltage when configured for voltage."""
|
||||
dmm.process("CONF:VOLT:DC")
|
||||
response = dmm.process("READ?")
|
||||
|
||||
assert response == "0.000000"
|
||||
|
||||
def test_read_query_curr_mode(self, dmm: MultimeterSim) -> None:
|
||||
"""Test READ? returns current when configured for current."""
|
||||
dmm.process("CONF:CURR:DC")
|
||||
response = dmm.process("READ?")
|
||||
|
||||
assert response == "0.000000"
|
||||
|
||||
def test_read_as_command_fails(self, dmm: MultimeterSim) -> None:
|
||||
"""Test READ (without ?) returns error."""
|
||||
response = dmm.process("READ")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
|
||||
class TestMultimeterWithPhysicsEngine:
|
||||
"""Tests for MultimeterSim with physics engine integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self) -> PhysicsEngine:
|
||||
"""Create physics engine instance."""
|
||||
return PhysicsEngine(update_rate_hz=100.0)
|
||||
|
||||
@pytest.fixture
|
||||
def dmm(self, engine: PhysicsEngine) -> MultimeterSim:
|
||||
"""Create multimeter instance with physics engine."""
|
||||
return MultimeterSim(physics_engine=engine)
|
||||
|
||||
def test_meas_volt_dc_returns_engine_voltage(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test MEAS:VOLT:DC? returns physics engine output voltage."""
|
||||
engine.set_input_voltage(5.0)
|
||||
engine.set_output_enabled(True)
|
||||
engine.step()
|
||||
|
||||
response = dmm.process("MEAS:VOLT:DC?")
|
||||
|
||||
# LDO model outputs ~3.3V nominal
|
||||
voltage = float(response)
|
||||
assert voltage > 3.0
|
||||
assert voltage < 4.0
|
||||
|
||||
def test_meas_volt_dc_returns_zero_when_disabled(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test MEAS:VOLT:DC? returns 0 when DUT output disabled."""
|
||||
engine.set_input_voltage(5.0)
|
||||
engine.set_output_enabled(False)
|
||||
engine.step()
|
||||
|
||||
response = dmm.process("MEAS:VOLT:DC?")
|
||||
|
||||
assert response == "0.000000"
|
||||
|
||||
def test_meas_curr_dc_returns_engine_current(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test MEAS:CURR:DC? returns physics engine load current."""
|
||||
engine.set_input_voltage(5.0)
|
||||
engine.set_load_current(0.1)
|
||||
engine.set_output_enabled(True)
|
||||
engine.step()
|
||||
|
||||
response = dmm.process("MEAS:CURR:DC?")
|
||||
|
||||
assert float(response) == pytest.approx(0.1, abs=0.001)
|
||||
|
||||
def test_meas_curr_dc_returns_zero_when_disabled(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test MEAS:CURR:DC? returns 0 when DUT output disabled."""
|
||||
engine.set_input_voltage(5.0)
|
||||
engine.set_load_current(0.1)
|
||||
engine.set_output_enabled(False)
|
||||
engine.step()
|
||||
|
||||
response = dmm.process("MEAS:CURR:DC?")
|
||||
|
||||
assert response == "0.000000"
|
||||
|
||||
def test_read_uses_configured_function(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test READ? respects configured measurement function."""
|
||||
engine.set_input_voltage(5.0)
|
||||
engine.set_load_current(0.1)
|
||||
engine.set_output_enabled(True)
|
||||
engine.step()
|
||||
|
||||
# Configure for current
|
||||
dmm.process("CONF:CURR:DC")
|
||||
response = dmm.process("READ?")
|
||||
|
||||
# Should return current, not voltage
|
||||
assert float(response) == pytest.approx(0.1, abs=0.001)
|
||||
|
||||
def test_reset_restores_voltage_mode(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test *RST restores default voltage measurement mode."""
|
||||
dmm.process("CONF:CURR:DC")
|
||||
dmm.process("*RST")
|
||||
|
||||
assert dmm.process("CONF?") == '"VOLT:DC"'
|
||||
|
||||
def test_voltage_changes_with_temperature(
|
||||
self, dmm: MultimeterSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test measured voltage changes with DUT temperature."""
|
||||
engine.set_input_voltage(5.0)
|
||||
engine.set_output_enabled(True)
|
||||
engine.step()
|
||||
|
||||
# Measure at initial temperature
|
||||
response1 = dmm.process("MEAS:VOLT:DC?")
|
||||
v1 = float(response1)
|
||||
|
||||
# Change chamber temperature and let settle
|
||||
engine.set_chamber_setpoint(85.0)
|
||||
for _ in range(5000): # Let temperature settle somewhat
|
||||
engine.step()
|
||||
|
||||
# Measure at elevated temperature
|
||||
response2 = dmm.process("MEAS:VOLT:DC?")
|
||||
v2 = float(response2)
|
||||
|
||||
# Output voltage should have changed (LDO has tempco)
|
||||
assert v1 != v2
|
||||
@@ -63,7 +63,7 @@ class TestThermalState:
|
||||
|
||||
# Should not raise
|
||||
hash(state)
|
||||
{state} # Can be added to a set
|
||||
_ = {state} # Can be added to a set
|
||||
|
||||
|
||||
class TestElectricalState:
|
||||
|
||||
352
tests/unit/test_power_supply.py
Normal file
352
tests/unit/test_power_supply.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Unit tests for power supply simulator."""
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
from py_dvt_ate.simulation.virtual.power_supply import PowerSupplySim
|
||||
|
||||
|
||||
class TestPowerSupplySimBasic:
|
||||
"""Tests for PowerSupplySim without physics engine."""
|
||||
|
||||
@pytest.fixture
|
||||
def psu(self) -> PowerSupplySim:
|
||||
"""Create power supply instance without physics engine."""
|
||||
return PowerSupplySim()
|
||||
|
||||
def test_creation(self, psu: PowerSupplySim) -> None:
|
||||
"""Test power supply can be created."""
|
||||
assert psu is not None
|
||||
assert psu.model == "PS-SIM-001"
|
||||
assert psu.manufacturer == "PyDVTATE"
|
||||
|
||||
def test_idn_query(self, psu: PowerSupplySim) -> None:
|
||||
"""Test *IDN? returns identification string."""
|
||||
response = psu.process("*IDN?")
|
||||
|
||||
assert "PyDVTATE" in response
|
||||
assert "PS-SIM-001" in response
|
||||
|
||||
def test_rst_command(self, psu: PowerSupplySim) -> None:
|
||||
"""Test *RST resets to defaults."""
|
||||
# Set non-default values
|
||||
psu.process("VOLT 12.0")
|
||||
psu.process("CURR 2.0")
|
||||
psu.process("OUTP ON")
|
||||
|
||||
# Reset
|
||||
response = psu.process("*RST")
|
||||
assert response == ""
|
||||
|
||||
# Check defaults restored
|
||||
assert psu.process("VOLT?") == "0.000"
|
||||
assert psu.process("CURR?") == "1.000"
|
||||
assert psu.process("OUTP?") == "0"
|
||||
|
||||
def test_opc_query(self, psu: PowerSupplySim) -> None:
|
||||
"""Test *OPC? returns 1."""
|
||||
response = psu.process("*OPC?")
|
||||
assert response == "1"
|
||||
|
||||
def test_unknown_command(self, psu: PowerSupplySim) -> None:
|
||||
"""Test unknown command returns error."""
|
||||
response = psu.process("INVALID:CMD")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Unknown command" in response
|
||||
|
||||
|
||||
class TestPowerSupplyVoltage:
|
||||
"""Tests for VOLT command."""
|
||||
|
||||
@pytest.fixture
|
||||
def psu(self) -> PowerSupplySim:
|
||||
"""Create power supply instance without physics engine."""
|
||||
return PowerSupplySim()
|
||||
|
||||
def test_volt_query_default(self, psu: PowerSupplySim) -> None:
|
||||
"""Test VOLT? returns default value."""
|
||||
response = psu.process("VOLT?")
|
||||
|
||||
assert response == "0.000"
|
||||
|
||||
def test_volt_set(self, psu: PowerSupplySim) -> None:
|
||||
"""Test VOLT sets value."""
|
||||
response = psu.process("VOLT 12.5")
|
||||
|
||||
assert response == ""
|
||||
assert psu.process("VOLT?") == "12.500"
|
||||
|
||||
def test_volt_set_decimal(self, psu: PowerSupplySim) -> None:
|
||||
"""Test VOLT accepts decimal values."""
|
||||
psu.process("VOLT 3.3")
|
||||
|
||||
assert psu.process("VOLT?") == "3.300"
|
||||
|
||||
def test_volt_set_negative_fails(self, psu: PowerSupplySim) -> None:
|
||||
"""Test VOLT rejects negative values."""
|
||||
response = psu.process("VOLT -5.0")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "negative" in response
|
||||
|
||||
def test_volt_set_invalid_value(self, psu: PowerSupplySim) -> None:
|
||||
"""Test VOLT with invalid value returns error."""
|
||||
response = psu.process("VOLT abc")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Invalid voltage" in response
|
||||
|
||||
def test_volt_set_no_argument(self, psu: PowerSupplySim) -> None:
|
||||
"""Test VOLT without argument returns error."""
|
||||
response = psu.process("VOLT")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "requires a value" in response
|
||||
|
||||
|
||||
class TestPowerSupplyCurrent:
|
||||
"""Tests for CURR command."""
|
||||
|
||||
@pytest.fixture
|
||||
def psu(self) -> PowerSupplySim:
|
||||
"""Create power supply instance without physics engine."""
|
||||
return PowerSupplySim()
|
||||
|
||||
def test_curr_query_default(self, psu: PowerSupplySim) -> None:
|
||||
"""Test CURR? returns default value."""
|
||||
response = psu.process("CURR?")
|
||||
|
||||
assert response == "1.000"
|
||||
|
||||
def test_curr_set(self, psu: PowerSupplySim) -> None:
|
||||
"""Test CURR sets value."""
|
||||
response = psu.process("CURR 0.5")
|
||||
|
||||
assert response == ""
|
||||
assert psu.process("CURR?") == "0.500"
|
||||
|
||||
def test_curr_set_negative_fails(self, psu: PowerSupplySim) -> None:
|
||||
"""Test CURR rejects negative values."""
|
||||
response = psu.process("CURR -1.0")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "negative" in response
|
||||
|
||||
def test_curr_set_invalid_value(self, psu: PowerSupplySim) -> None:
|
||||
"""Test CURR with invalid value returns error."""
|
||||
response = psu.process("CURR xyz")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Invalid current" in response
|
||||
|
||||
def test_curr_set_no_argument(self, psu: PowerSupplySim) -> None:
|
||||
"""Test CURR without argument returns error."""
|
||||
response = psu.process("CURR")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "requires a value" in response
|
||||
|
||||
|
||||
class TestPowerSupplyOutput:
|
||||
"""Tests for OUTP command."""
|
||||
|
||||
@pytest.fixture
|
||||
def psu(self) -> PowerSupplySim:
|
||||
"""Create power supply instance without physics engine."""
|
||||
return PowerSupplySim()
|
||||
|
||||
def test_outp_query_default(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP? returns default value (off)."""
|
||||
response = psu.process("OUTP?")
|
||||
|
||||
assert response == "0"
|
||||
|
||||
def test_outp_set_on(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP ON enables output."""
|
||||
response = psu.process("OUTP ON")
|
||||
|
||||
assert response == ""
|
||||
assert psu.process("OUTP?") == "1"
|
||||
|
||||
def test_outp_set_1(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP 1 enables output."""
|
||||
psu.process("OUTP 1")
|
||||
|
||||
assert psu.process("OUTP?") == "1"
|
||||
|
||||
def test_outp_set_off(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP OFF disables output."""
|
||||
psu.process("OUTP ON")
|
||||
psu.process("OUTP OFF")
|
||||
|
||||
assert psu.process("OUTP?") == "0"
|
||||
|
||||
def test_outp_set_0(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP 0 disables output."""
|
||||
psu.process("OUTP ON")
|
||||
psu.process("OUTP 0")
|
||||
|
||||
assert psu.process("OUTP?") == "0"
|
||||
|
||||
def test_outp_set_invalid(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP with invalid value returns error."""
|
||||
response = psu.process("OUTP MAYBE")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Invalid output state" in response
|
||||
|
||||
def test_outp_set_no_argument(self, psu: PowerSupplySim) -> None:
|
||||
"""Test OUTP without argument returns error."""
|
||||
response = psu.process("OUTP")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "requires a value" in response
|
||||
|
||||
|
||||
class TestPowerSupplyMeasurement:
|
||||
"""Tests for MEAS commands."""
|
||||
|
||||
@pytest.fixture
|
||||
def psu(self) -> PowerSupplySim:
|
||||
"""Create power supply instance without physics engine."""
|
||||
return PowerSupplySim()
|
||||
|
||||
def test_meas_volt_when_off(self, psu: PowerSupplySim) -> None:
|
||||
"""Test MEAS:VOLT? returns 0 when output is off."""
|
||||
psu.process("VOLT 12.0")
|
||||
response = psu.process("MEAS:VOLT?")
|
||||
|
||||
assert response == "0.000"
|
||||
|
||||
def test_meas_volt_when_on_no_engine(self, psu: PowerSupplySim) -> None:
|
||||
"""Test MEAS:VOLT? returns setpoint when on without engine."""
|
||||
psu.process("VOLT 12.0")
|
||||
psu.process("OUTP ON")
|
||||
response = psu.process("MEAS:VOLT?")
|
||||
|
||||
assert response == "12.000"
|
||||
|
||||
def test_meas_volt_as_command_fails(self, psu: PowerSupplySim) -> None:
|
||||
"""Test MEAS:VOLT (without ?) returns error."""
|
||||
response = psu.process("MEAS:VOLT 5.0")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
def test_meas_curr_when_off(self, psu: PowerSupplySim) -> None:
|
||||
"""Test MEAS:CURR? returns 0 when output is off."""
|
||||
response = psu.process("MEAS:CURR?")
|
||||
|
||||
assert response == "0.000"
|
||||
|
||||
def test_meas_curr_when_on_no_engine(self, psu: PowerSupplySim) -> None:
|
||||
"""Test MEAS:CURR? returns 0 when on without engine."""
|
||||
psu.process("OUTP ON")
|
||||
response = psu.process("MEAS:CURR?")
|
||||
|
||||
assert response == "0.000"
|
||||
|
||||
def test_meas_curr_as_command_fails(self, psu: PowerSupplySim) -> None:
|
||||
"""Test MEAS:CURR (without ?) returns error."""
|
||||
response = psu.process("MEAS:CURR 0.1")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
|
||||
class TestPowerSupplyWithPhysicsEngine:
|
||||
"""Tests for PowerSupplySim with physics engine integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self) -> PhysicsEngine:
|
||||
"""Create physics engine instance."""
|
||||
return PhysicsEngine(update_rate_hz=100.0)
|
||||
|
||||
@pytest.fixture
|
||||
def psu(self, engine: PhysicsEngine) -> PowerSupplySim:
|
||||
"""Create power supply instance with physics engine."""
|
||||
return PowerSupplySim(physics_engine=engine)
|
||||
|
||||
def test_outp_on_enables_engine_output(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test OUTP ON enables physics engine output."""
|
||||
psu.process("VOLT 5.0")
|
||||
psu.process("OUTP ON")
|
||||
|
||||
assert engine.is_output_enabled is True
|
||||
|
||||
def test_outp_off_disables_engine_output(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test OUTP OFF disables physics engine output."""
|
||||
psu.process("OUTP ON")
|
||||
psu.process("OUTP OFF")
|
||||
|
||||
assert engine.is_output_enabled is False
|
||||
|
||||
def test_volt_updates_engine_when_on(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test VOLT updates engine input voltage when output is on."""
|
||||
psu.process("OUTP ON")
|
||||
psu.process("VOLT 5.0")
|
||||
|
||||
electrical = engine.get_electrical_state()
|
||||
assert electrical.input_voltage == pytest.approx(5.0)
|
||||
|
||||
def test_volt_does_not_update_engine_when_off(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test VOLT does not update engine when output is off."""
|
||||
psu.process("VOLT 5.0")
|
||||
|
||||
electrical = engine.get_electrical_state()
|
||||
assert electrical.input_voltage == pytest.approx(0.0)
|
||||
|
||||
def test_meas_volt_returns_engine_voltage(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test MEAS:VOLT? returns physics engine voltage."""
|
||||
psu.process("VOLT 5.0")
|
||||
psu.process("OUTP ON")
|
||||
|
||||
response = psu.process("MEAS:VOLT?")
|
||||
assert response == "5.000"
|
||||
|
||||
def test_meas_curr_returns_engine_current(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test MEAS:CURR? returns total current from engine."""
|
||||
psu.process("VOLT 5.0")
|
||||
psu.process("OUTP ON")
|
||||
engine.set_load_current(0.1)
|
||||
|
||||
# Step engine to allow calculations
|
||||
engine.step()
|
||||
|
||||
response = psu.process("MEAS:CURR?")
|
||||
# Should include load current + quiescent current
|
||||
assert float(response) > 0.0
|
||||
|
||||
def test_reset_disables_engine_output(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test *RST disables physics engine output."""
|
||||
psu.process("VOLT 5.0")
|
||||
psu.process("OUTP ON")
|
||||
psu.process("*RST")
|
||||
|
||||
assert engine.is_output_enabled is False
|
||||
|
||||
def test_reset_sets_engine_voltage_zero(
|
||||
self, psu: PowerSupplySim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test *RST sets physics engine voltage to zero."""
|
||||
psu.process("VOLT 5.0")
|
||||
psu.process("OUTP ON")
|
||||
psu.process("*RST")
|
||||
|
||||
electrical = engine.get_electrical_state()
|
||||
assert electrical.input_voltage == pytest.approx(0.0)
|
||||
308
tests/unit/test_repository.py
Normal file
308
tests/unit/test_repository.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""Unit tests for data repository."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.data.models import Measurement, TestStatus
|
||||
from py_dvt_ate.data.repository import SQLiteRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db():
|
||||
"""Create a temporary database for testing."""
|
||||
# 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"
|
||||
yield db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(temp_db):
|
||||
"""Create a repository instance for testing."""
|
||||
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):
|
||||
"""Test creating a new test run."""
|
||||
config = {"temperature": 25.0, "voltage": 3.3}
|
||||
run_id = repository.create_run(
|
||||
test_name="TempCo Test",
|
||||
config=config,
|
||||
operator="Test Engineer",
|
||||
description="Test description",
|
||||
)
|
||||
|
||||
assert run_id is not None
|
||||
|
||||
# Verify run was created
|
||||
run = repository.get_run(run_id)
|
||||
assert run.test_name == "TempCo Test"
|
||||
assert run.operator == "Test Engineer"
|
||||
assert run.description == "Test description"
|
||||
assert run.status == TestStatus.PENDING
|
||||
|
||||
|
||||
def test_update_run_status(repository):
|
||||
"""Test updating test run status."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
repository.update_run_status(run_id, TestStatus.RUNNING)
|
||||
run = repository.get_run(run_id)
|
||||
assert run.status == TestStatus.RUNNING
|
||||
|
||||
repository.update_run_status(run_id, TestStatus.PASSED)
|
||||
run = repository.get_run(run_id)
|
||||
assert run.status == TestStatus.PASSED
|
||||
|
||||
|
||||
def test_complete_run(repository):
|
||||
"""Test completing a test run."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
repository.complete_run(run_id, TestStatus.PASSED)
|
||||
run = repository.get_run(run_id)
|
||||
|
||||
assert run.status == TestStatus.PASSED
|
||||
assert run.completed_at is not None
|
||||
|
||||
|
||||
def test_save_result(repository):
|
||||
"""Test saving a test result."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
repository.save_result(
|
||||
run_id=run_id,
|
||||
parameter="output_voltage",
|
||||
value=3.305,
|
||||
unit="V",
|
||||
lower_limit=3.267,
|
||||
upper_limit=3.333,
|
||||
)
|
||||
|
||||
results = repository.get_results(run_id)
|
||||
assert len(results) == 1
|
||||
|
||||
result = results[0]
|
||||
assert result.parameter == "output_voltage"
|
||||
assert result.value == 3.305
|
||||
assert result.unit == "V"
|
||||
assert result.lower_limit == 3.267
|
||||
assert result.upper_limit == 3.333
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
def test_save_result_fail(repository):
|
||||
"""Test saving a failing test result."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
repository.save_result(
|
||||
run_id=run_id,
|
||||
parameter="output_voltage",
|
||||
value=3.350, # Outside upper limit
|
||||
unit="V",
|
||||
lower_limit=3.267,
|
||||
upper_limit=3.333,
|
||||
)
|
||||
|
||||
results = repository.get_results(run_id)
|
||||
result = results[0]
|
||||
assert result.passed is False
|
||||
|
||||
|
||||
def test_save_result_no_limits(repository):
|
||||
"""Test saving a result without limits."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
repository.save_result(
|
||||
run_id=run_id,
|
||||
parameter="temperature",
|
||||
value=25.5,
|
||||
unit="°C",
|
||||
)
|
||||
|
||||
results = repository.get_results(run_id)
|
||||
result = results[0]
|
||||
assert result.passed is None # No limits defined
|
||||
|
||||
|
||||
def test_save_measurements(repository):
|
||||
"""Test saving time-series measurements to Parquet."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
measurements = [
|
||||
Measurement(
|
||||
timestamp=1234567890.0,
|
||||
parameter="voltage",
|
||||
value=3.3,
|
||||
unit="V",
|
||||
temperature=25.0,
|
||||
input_voltage=5.0,
|
||||
load_current=0.1,
|
||||
),
|
||||
Measurement(
|
||||
timestamp=1234567891.0,
|
||||
parameter="voltage",
|
||||
value=3.31,
|
||||
unit="V",
|
||||
temperature=25.1,
|
||||
input_voltage=5.0,
|
||||
load_current=0.1,
|
||||
),
|
||||
]
|
||||
|
||||
repository.save_measurements(run_id, measurements)
|
||||
|
||||
# Verify measurements were saved
|
||||
df = repository.get_measurements_dataframe(run_id)
|
||||
assert df is not None
|
||||
assert len(df) == 2
|
||||
assert list(df["parameter"]) == ["voltage", "voltage"]
|
||||
assert list(df["value"]) == [3.3, 3.31]
|
||||
|
||||
|
||||
def test_save_measurements_append(repository):
|
||||
"""Test appending measurements to existing Parquet file."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
# Save first batch
|
||||
measurements1 = [
|
||||
Measurement(
|
||||
timestamp=1234567890.0,
|
||||
parameter="voltage",
|
||||
value=3.3,
|
||||
unit="V",
|
||||
)
|
||||
]
|
||||
repository.save_measurements(run_id, measurements1)
|
||||
|
||||
# Save second batch
|
||||
measurements2 = [
|
||||
Measurement(
|
||||
timestamp=1234567891.0,
|
||||
parameter="voltage",
|
||||
value=3.31,
|
||||
unit="V",
|
||||
)
|
||||
]
|
||||
repository.save_measurements(run_id, measurements2)
|
||||
|
||||
# Verify both batches are present
|
||||
df = repository.get_measurements_dataframe(run_id)
|
||||
assert df is not None
|
||||
assert len(df) == 2
|
||||
|
||||
|
||||
def test_get_measurements_nonexistent(repository):
|
||||
"""Test getting measurements for non-existent run."""
|
||||
fake_id = uuid4()
|
||||
df = repository.get_measurements_dataframe(fake_id)
|
||||
assert df is None
|
||||
|
||||
|
||||
def test_save_empty_measurements(repository):
|
||||
"""Test saving empty measurement list."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
repository.save_measurements(run_id, [])
|
||||
|
||||
df = repository.get_measurements_dataframe(run_id)
|
||||
assert df is None
|
||||
|
||||
|
||||
def test_get_nonexistent_run(repository):
|
||||
"""Test getting a non-existent run raises error."""
|
||||
fake_id = uuid4()
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
repository.get_run(fake_id)
|
||||
|
||||
|
||||
def test_multiple_results(repository):
|
||||
"""Test saving and retrieving multiple results."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
repository.save_result(run_id, "voltage", 3.3, "V")
|
||||
repository.save_result(run_id, "current", 50.0, "uA")
|
||||
repository.save_result(run_id, "temperature", 25.0, "°C")
|
||||
|
||||
results = repository.get_results(run_id)
|
||||
assert len(results) == 3
|
||||
|
||||
parameters = {r.parameter for r in results}
|
||||
assert parameters == {"voltage", "current", "temperature"}
|
||||
|
||||
|
||||
def test_custom_measurements_dir(temp_db):
|
||||
"""Test using a custom measurements directory."""
|
||||
import gc
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
measurements_dir = Path(tmpdir) / "custom_measurements"
|
||||
repo = SQLiteRepository(temp_db, measurements_dir=measurements_dir)
|
||||
|
||||
run_id = repo.create_run("Test", config={})
|
||||
measurements = [
|
||||
Measurement(
|
||||
timestamp=1234567890.0,
|
||||
parameter="voltage",
|
||||
value=3.3,
|
||||
unit="V",
|
||||
)
|
||||
]
|
||||
repo.save_measurements(run_id, measurements)
|
||||
|
||||
# Verify file is in custom directory
|
||||
expected_path = measurements_dir / f"run_{run_id}" / "measurements.parquet"
|
||||
assert expected_path.exists()
|
||||
|
||||
# Clean up repository before temp directory cleanup
|
||||
del repo
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_parquet_schema(repository):
|
||||
"""Test that Parquet file has correct schema."""
|
||||
run_id = repository.create_run("Test", config={})
|
||||
|
||||
measurements = [
|
||||
Measurement(
|
||||
timestamp=1234567890.123,
|
||||
parameter="voltage",
|
||||
value=3.3,
|
||||
unit="V",
|
||||
temperature=25.5,
|
||||
input_voltage=5.0,
|
||||
load_current=0.1,
|
||||
)
|
||||
]
|
||||
repository.save_measurements(run_id, measurements)
|
||||
|
||||
df = repository.get_measurements_dataframe(run_id)
|
||||
assert df is not None
|
||||
|
||||
# Check columns
|
||||
expected_columns = {
|
||||
"timestamp",
|
||||
"parameter",
|
||||
"value",
|
||||
"unit",
|
||||
"temperature",
|
||||
"input_voltage",
|
||||
"load_current",
|
||||
}
|
||||
assert set(df.columns) == expected_columns
|
||||
|
||||
# Check data types (approximately)
|
||||
assert pd.api.types.is_float_dtype(df["timestamp"])
|
||||
assert pd.api.types.is_string_dtype(df["parameter"]) or pd.api.types.is_object_dtype(
|
||||
df["parameter"]
|
||||
)
|
||||
assert pd.api.types.is_float_dtype(df["value"])
|
||||
203
tests/unit/test_scpi_parser.py
Normal file
203
tests/unit/test_scpi_parser.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Unit tests for SCPI command parsing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.instruments.scpi import SCPICommand, SCPIParser
|
||||
|
||||
|
||||
class TestSCPICommand:
|
||||
"""Tests for the SCPICommand dataclass."""
|
||||
|
||||
def test_creation(self) -> None:
|
||||
"""Test SCPICommand can be created with valid values."""
|
||||
cmd = SCPICommand(
|
||||
header="VOLT",
|
||||
arguments=["3.3"],
|
||||
is_query=False,
|
||||
)
|
||||
|
||||
assert cmd.header == "VOLT"
|
||||
assert cmd.arguments == ["3.3"]
|
||||
assert cmd.is_query is False
|
||||
|
||||
def test_keyword_for_command(self) -> None:
|
||||
"""Test keyword property for regular command."""
|
||||
cmd = SCPICommand(header="VOLT", arguments=["3.3"], is_query=False)
|
||||
|
||||
assert cmd.keyword == "VOLT"
|
||||
|
||||
def test_keyword_for_query(self) -> None:
|
||||
"""Test keyword property strips '?' from query."""
|
||||
cmd = SCPICommand(header="VOLT?", arguments=[], is_query=True)
|
||||
|
||||
assert cmd.keyword == "VOLT"
|
||||
|
||||
def test_keyword_for_nested_command(self) -> None:
|
||||
"""Test keyword property for nested SCPI command."""
|
||||
cmd = SCPICommand(header="TEMP:SETPOINT?", arguments=[], is_query=True)
|
||||
|
||||
assert cmd.keyword == "TEMP:SETPOINT"
|
||||
|
||||
|
||||
class TestSCPIParser:
|
||||
"""Tests for the SCPIParser class."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self) -> SCPIParser:
|
||||
"""Create parser instance for tests."""
|
||||
return SCPIParser()
|
||||
|
||||
def test_parse_simple_query(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing simple query command."""
|
||||
cmd = parser.parse("*IDN?")
|
||||
|
||||
assert cmd.header == "*IDN?"
|
||||
assert cmd.arguments == []
|
||||
assert cmd.is_query is True
|
||||
assert cmd.keyword == "*IDN"
|
||||
|
||||
def test_parse_simple_command(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing simple command without arguments."""
|
||||
cmd = parser.parse("*RST")
|
||||
|
||||
assert cmd.header == "*RST"
|
||||
assert cmd.arguments == []
|
||||
assert cmd.is_query is False
|
||||
assert cmd.keyword == "*RST"
|
||||
|
||||
def test_parse_command_with_single_argument(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing command with single numeric argument."""
|
||||
cmd = parser.parse("VOLT 3.3")
|
||||
|
||||
assert cmd.header == "VOLT"
|
||||
assert cmd.arguments == ["3.3"]
|
||||
assert cmd.is_query is False
|
||||
|
||||
def test_parse_command_with_multiple_arguments(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing command with comma-separated arguments."""
|
||||
cmd = parser.parse("CONF:VOLT:DC 10,0.001")
|
||||
|
||||
assert cmd.header == "CONF:VOLT:DC"
|
||||
assert cmd.arguments == ["10", "0.001"]
|
||||
assert cmd.is_query is False
|
||||
|
||||
def test_parse_nested_scpi_command(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing nested SCPI command hierarchy."""
|
||||
cmd = parser.parse("TEMP:SETPOINT 85.0")
|
||||
|
||||
assert cmd.header == "TEMP:SETPOINT"
|
||||
assert cmd.arguments == ["85.0"]
|
||||
assert cmd.is_query is False
|
||||
assert cmd.keyword == "TEMP:SETPOINT"
|
||||
|
||||
def test_parse_nested_scpi_query(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing nested SCPI query."""
|
||||
cmd = parser.parse("TEMP:SETPOINT?")
|
||||
|
||||
assert cmd.header == "TEMP:SETPOINT?"
|
||||
assert cmd.arguments == []
|
||||
assert cmd.is_query is True
|
||||
|
||||
def test_parse_ieee_common_commands(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing IEEE 488.2 common commands."""
|
||||
# Identity query
|
||||
cmd = parser.parse("*IDN?")
|
||||
assert cmd.is_query is True
|
||||
assert cmd.keyword == "*IDN"
|
||||
|
||||
# Reset
|
||||
cmd = parser.parse("*RST")
|
||||
assert cmd.is_query is False
|
||||
assert cmd.keyword == "*RST"
|
||||
|
||||
# Clear status
|
||||
cmd = parser.parse("*CLS")
|
||||
assert cmd.is_query is False
|
||||
assert cmd.keyword == "*CLS"
|
||||
|
||||
# Operation complete query
|
||||
cmd = parser.parse("*OPC?")
|
||||
assert cmd.is_query is True
|
||||
assert cmd.keyword == "*OPC"
|
||||
|
||||
def test_parse_strips_whitespace(self, parser: SCPIParser) -> None:
|
||||
"""Test parser strips leading and trailing whitespace."""
|
||||
cmd = parser.parse(" VOLT 3.3 ")
|
||||
|
||||
assert cmd.header == "VOLT"
|
||||
assert cmd.arguments == ["3.3"]
|
||||
|
||||
def test_parse_strips_argument_whitespace(self, parser: SCPIParser) -> None:
|
||||
"""Test parser strips whitespace from arguments."""
|
||||
cmd = parser.parse("CONF:VOLT:DC 10 , 0.001 ")
|
||||
|
||||
assert cmd.arguments == ["10", "0.001"]
|
||||
|
||||
def test_parse_empty_string(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing empty string returns empty command."""
|
||||
cmd = parser.parse("")
|
||||
|
||||
assert cmd.header == ""
|
||||
assert cmd.arguments == []
|
||||
assert cmd.is_query is False
|
||||
|
||||
def test_parse_whitespace_only(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing whitespace-only string returns empty command."""
|
||||
cmd = parser.parse(" ")
|
||||
|
||||
assert cmd.header == ""
|
||||
assert cmd.arguments == []
|
||||
assert cmd.is_query is False
|
||||
|
||||
def test_parse_output_on_off(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing output enable/disable commands."""
|
||||
cmd_on = parser.parse("OUTP ON")
|
||||
assert cmd_on.arguments == ["ON"]
|
||||
|
||||
cmd_off = parser.parse("OUTP OFF")
|
||||
assert cmd_off.arguments == ["OFF"]
|
||||
|
||||
cmd_1 = parser.parse("OUTP 1")
|
||||
assert cmd_1.arguments == ["1"]
|
||||
|
||||
cmd_0 = parser.parse("OUTP 0")
|
||||
assert cmd_0.arguments == ["0"]
|
||||
|
||||
def test_parse_channel_select(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing channel selection commands."""
|
||||
cmd = parser.parse("INST:SEL CH1")
|
||||
|
||||
assert cmd.header == "INST:SEL"
|
||||
assert cmd.arguments == ["CH1"]
|
||||
|
||||
def test_parse_measurement_query(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing measurement query commands."""
|
||||
cmd = parser.parse("MEAS:VOLT:DC?")
|
||||
|
||||
assert cmd.header == "MEAS:VOLT:DC?"
|
||||
assert cmd.is_query is True
|
||||
assert cmd.keyword == "MEAS:VOLT:DC"
|
||||
|
||||
def test_parse_measurement_with_range(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing measurement query with range argument."""
|
||||
cmd = parser.parse("MEAS:VOLT:DC? AUTO")
|
||||
|
||||
assert cmd.header == "MEAS:VOLT:DC?"
|
||||
assert cmd.arguments == ["AUTO"]
|
||||
assert cmd.is_query is True
|
||||
|
||||
def test_parse_system_error_query(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing system error query."""
|
||||
cmd = parser.parse("SYST:ERR?")
|
||||
|
||||
assert cmd.header == "SYST:ERR?"
|
||||
assert cmd.is_query is True
|
||||
assert cmd.keyword == "SYST:ERR"
|
||||
|
||||
def test_parse_nplc_setting(self, parser: SCPIParser) -> None:
|
||||
"""Test parsing NPLC (integration time) command."""
|
||||
cmd = parser.parse("SENS:VOLT:DC:NPLC 10")
|
||||
|
||||
assert cmd.header == "SENS:VOLT:DC:NPLC"
|
||||
assert cmd.arguments == ["10"]
|
||||
assert cmd.is_query is False
|
||||
215
tests/unit/test_thermal_chamber.py
Normal file
215
tests/unit/test_thermal_chamber.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Unit tests for thermal chamber simulator."""
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||
from py_dvt_ate.simulation.virtual.chamber import ThermalChamberSim
|
||||
|
||||
|
||||
class TestThermalChamberSimBasic:
|
||||
"""Tests for ThermalChamberSim without physics engine."""
|
||||
|
||||
@pytest.fixture
|
||||
def chamber(self) -> ThermalChamberSim:
|
||||
"""Create chamber instance without physics engine."""
|
||||
return ThermalChamberSim()
|
||||
|
||||
def test_creation(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test chamber can be created."""
|
||||
assert chamber is not None
|
||||
assert chamber.model == "TC-SIM-001"
|
||||
assert chamber.manufacturer == "PyDVTATE"
|
||||
|
||||
def test_idn_query(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test *IDN? returns identification string."""
|
||||
response = chamber.process("*IDN?")
|
||||
|
||||
assert "PyDVTATE" in response
|
||||
assert "TC-SIM-001" in response
|
||||
|
||||
def test_rst_command(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test *RST resets to defaults."""
|
||||
# Set non-default value
|
||||
chamber.process("TEMP:SETPOINT 85.0")
|
||||
assert chamber.process("TEMP:SETPOINT?") == "85.00"
|
||||
|
||||
# Reset
|
||||
response = chamber.process("*RST")
|
||||
assert response == ""
|
||||
assert chamber.process("TEMP:SETPOINT?") == "25.00"
|
||||
|
||||
def test_opc_query(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test *OPC? returns 1."""
|
||||
response = chamber.process("*OPC?")
|
||||
assert response == "1"
|
||||
|
||||
def test_unknown_command(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test unknown command returns error."""
|
||||
response = chamber.process("INVALID:CMD")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Unknown command" in response
|
||||
|
||||
|
||||
class TestThermalChamberSetpoint:
|
||||
"""Tests for TEMP:SETPOINT command."""
|
||||
|
||||
@pytest.fixture
|
||||
def chamber(self) -> ThermalChamberSim:
|
||||
"""Create chamber instance without physics engine."""
|
||||
return ThermalChamberSim()
|
||||
|
||||
def test_setpoint_query_default(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:SETPOINT? returns default value."""
|
||||
response = chamber.process("TEMP:SETPOINT?")
|
||||
|
||||
assert response == "25.00"
|
||||
|
||||
def test_setpoint_set(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:SETPOINT sets value."""
|
||||
response = chamber.process("TEMP:SETPOINT 85.0")
|
||||
|
||||
assert response == ""
|
||||
assert chamber.process("TEMP:SETPOINT?") == "85.00"
|
||||
|
||||
def test_setpoint_set_negative(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:SETPOINT accepts negative values."""
|
||||
chamber.process("TEMP:SETPOINT -40.0")
|
||||
|
||||
assert chamber.process("TEMP:SETPOINT?") == "-40.00"
|
||||
|
||||
def test_setpoint_set_invalid_value(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:SETPOINT with invalid value returns error."""
|
||||
response = chamber.process("TEMP:SETPOINT abc")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "Invalid temperature" in response
|
||||
|
||||
def test_setpoint_set_no_argument(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:SETPOINT without argument returns error."""
|
||||
response = chamber.process("TEMP:SETPOINT")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "requires a value" in response
|
||||
|
||||
|
||||
class TestThermalChamberActual:
|
||||
"""Tests for TEMP:ACTUAL? query."""
|
||||
|
||||
@pytest.fixture
|
||||
def chamber(self) -> ThermalChamberSim:
|
||||
"""Create chamber instance without physics engine."""
|
||||
return ThermalChamberSim()
|
||||
|
||||
def test_actual_query_without_engine(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:ACTUAL? returns setpoint when no physics engine."""
|
||||
chamber.process("TEMP:SETPOINT 50.0")
|
||||
response = chamber.process("TEMP:ACTUAL?")
|
||||
|
||||
# Without physics engine, returns setpoint
|
||||
assert response == "50.00"
|
||||
|
||||
def test_actual_as_command_fails(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:ACTUAL (without ?) returns error."""
|
||||
response = chamber.process("TEMP:ACTUAL 25.0")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
|
||||
class TestThermalChamberStability:
|
||||
"""Tests for TEMP:STAB? query."""
|
||||
|
||||
@pytest.fixture
|
||||
def chamber(self) -> ThermalChamberSim:
|
||||
"""Create chamber instance without physics engine."""
|
||||
return ThermalChamberSim()
|
||||
|
||||
def test_stab_query_without_engine(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:STAB? returns 1 when no physics engine."""
|
||||
response = chamber.process("TEMP:STAB?")
|
||||
|
||||
# Without physics engine, assume stable
|
||||
assert response == "1"
|
||||
|
||||
def test_stab_as_command_fails(self, chamber: ThermalChamberSim) -> None:
|
||||
"""Test TEMP:STAB (without ?) returns error."""
|
||||
response = chamber.process("TEMP:STAB 1")
|
||||
|
||||
assert response.startswith("ERROR:")
|
||||
assert "query only" in response
|
||||
|
||||
|
||||
class TestThermalChamberWithPhysicsEngine:
|
||||
"""Tests for ThermalChamberSim with physics engine integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self) -> PhysicsEngine:
|
||||
"""Create physics engine instance."""
|
||||
return PhysicsEngine(update_rate_hz=100.0)
|
||||
|
||||
@pytest.fixture
|
||||
def chamber(self, engine: PhysicsEngine) -> ThermalChamberSim:
|
||||
"""Create chamber instance with physics engine."""
|
||||
return ThermalChamberSim(physics_engine=engine)
|
||||
|
||||
def test_setpoint_updates_engine(
|
||||
self, chamber: ThermalChamberSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test TEMP:SETPOINT updates physics engine."""
|
||||
chamber.process("TEMP:SETPOINT 85.0")
|
||||
|
||||
# Step the engine and check thermal state
|
||||
thermal = engine.get_thermal_state()
|
||||
# Initial chamber temp is 25, will start moving towards 85
|
||||
assert thermal.chamber_temperature == pytest.approx(25.0, abs=0.1)
|
||||
|
||||
# After many steps, should approach setpoint
|
||||
for _ in range(10000): # 100 seconds at 100Hz
|
||||
engine.step()
|
||||
|
||||
thermal = engine.get_thermal_state()
|
||||
# Should be closer to setpoint (but not quite there due to time constant)
|
||||
assert thermal.chamber_temperature > 80.0
|
||||
|
||||
def test_actual_query_returns_engine_temperature(
|
||||
self, chamber: ThermalChamberSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test TEMP:ACTUAL? returns physics engine temperature."""
|
||||
response = chamber.process("TEMP:ACTUAL?")
|
||||
|
||||
# Should match initial chamber temperature
|
||||
assert response == "25.00"
|
||||
|
||||
def test_stability_when_at_setpoint(
|
||||
self, chamber: ThermalChamberSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test TEMP:STAB? returns 1 when at setpoint."""
|
||||
# Default setpoint is 25, engine starts at 25
|
||||
response = chamber.process("TEMP:STAB?")
|
||||
|
||||
assert response == "1"
|
||||
|
||||
def test_stability_when_settling(
|
||||
self, chamber: ThermalChamberSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test TEMP:STAB? returns 0 when settling."""
|
||||
# Set new setpoint far from current temperature
|
||||
chamber.process("TEMP:SETPOINT 85.0")
|
||||
|
||||
# Step once to ensure engine updates
|
||||
engine.step()
|
||||
|
||||
# Should not be stable yet
|
||||
response = chamber.process("TEMP:STAB?")
|
||||
assert response == "0"
|
||||
|
||||
def test_reset_updates_engine(
|
||||
self, chamber: ThermalChamberSim, engine: PhysicsEngine
|
||||
) -> None:
|
||||
"""Test *RST resets both chamber and engine setpoint."""
|
||||
chamber.process("TEMP:SETPOINT 85.0")
|
||||
chamber.process("*RST")
|
||||
|
||||
# Check setpoint is back to default
|
||||
assert chamber.process("TEMP:SETPOINT?") == "25.00"
|
||||
263
tests/unit/test_transport.py
Normal file
263
tests/unit/test_transport.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Unit tests for transport layer."""
|
||||
|
||||
import socket
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from py_dvt_ate.instruments.transport.tcp import TCPTransport
|
||||
|
||||
|
||||
class TestTCPTransport:
|
||||
"""Tests for TCPTransport class."""
|
||||
|
||||
@pytest.fixture
|
||||
def transport(self) -> TCPTransport:
|
||||
"""Create transport instance for tests."""
|
||||
return TCPTransport("localhost", 5025, timeout=1.0)
|
||||
|
||||
def test_creation(self, transport: TCPTransport) -> None:
|
||||
"""Test TCPTransport can be created with valid parameters."""
|
||||
assert transport.host == "localhost"
|
||||
assert transport.port == 5025
|
||||
assert not transport.is_connected
|
||||
|
||||
def test_repr(self, transport: TCPTransport) -> None:
|
||||
"""Test string representation."""
|
||||
assert "localhost:5025" in repr(transport)
|
||||
assert "disconnected" in repr(transport)
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_connect_success(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test successful connection."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
transport.connect()
|
||||
|
||||
assert transport.is_connected
|
||||
mock_socket_class.assert_called_once_with(
|
||||
socket.AF_INET, socket.SOCK_STREAM
|
||||
)
|
||||
mock_sock.settimeout.assert_called_once_with(1.0)
|
||||
mock_sock.connect.assert_called_once_with(("localhost", 5025))
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_connect_already_connected(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test connecting when already connected raises error."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
transport.connect()
|
||||
|
||||
with pytest.raises(ConnectionError, match="Already connected"):
|
||||
transport.connect()
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_connect_failure(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test connection failure raises ConnectionError."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
mock_sock.connect.side_effect = OSError("Connection refused")
|
||||
|
||||
with pytest.raises(ConnectionError, match="Failed to connect"):
|
||||
transport.connect()
|
||||
|
||||
assert not transport.is_connected
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_disconnect(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test disconnect closes socket."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
transport.connect()
|
||||
transport.disconnect()
|
||||
|
||||
mock_sock.close.assert_called_once()
|
||||
assert not transport.is_connected
|
||||
|
||||
def test_disconnect_when_not_connected(self, transport: TCPTransport) -> None:
|
||||
"""Test disconnect is idempotent."""
|
||||
transport.disconnect() # Should not raise
|
||||
assert not transport.is_connected
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_write(self, mock_socket_class: Mock, transport: TCPTransport) -> None:
|
||||
"""Test write sends command with newline."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
transport.connect()
|
||||
transport.write("*IDN?")
|
||||
|
||||
mock_sock.sendall.assert_called_once_with(b"*IDN?\n")
|
||||
|
||||
def test_write_not_connected(self, transport: TCPTransport) -> None:
|
||||
"""Test write when not connected raises error."""
|
||||
with pytest.raises(ConnectionError, match="Not connected"):
|
||||
transport.write("*IDN?")
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_write_failure(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test write failure raises IOError."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
mock_sock.sendall.side_effect = OSError("Write failed")
|
||||
|
||||
transport.connect()
|
||||
|
||||
with pytest.raises(OSError, match="Write failed"):
|
||||
transport.write("*IDN?")
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_read(self, mock_socket_class: Mock, transport: TCPTransport) -> None:
|
||||
"""Test read receives response until newline."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
# Simulate receiving "OK\n" byte by byte
|
||||
mock_sock.recv.side_effect = [b"O", b"K", b"\n"]
|
||||
|
||||
transport.connect()
|
||||
response = transport.read()
|
||||
|
||||
assert response == "OK"
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_read_with_timeout(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test read with custom timeout."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
mock_sock.gettimeout.return_value = 1.0
|
||||
|
||||
# Simulate receiving "OK\n"
|
||||
mock_sock.recv.side_effect = [b"O", b"K", b"\n"]
|
||||
|
||||
transport.connect()
|
||||
response = transport.read(timeout=2.0)
|
||||
|
||||
assert response == "OK"
|
||||
# Verify timeout was changed and restored
|
||||
assert mock_sock.settimeout.call_count == 3 # connect + custom + restore
|
||||
mock_sock.settimeout.assert_any_call(2.0)
|
||||
mock_sock.settimeout.assert_any_call(1.0)
|
||||
|
||||
def test_read_not_connected(self, transport: TCPTransport) -> None:
|
||||
"""Test read when not connected raises error."""
|
||||
with pytest.raises(ConnectionError, match="Not connected"):
|
||||
transport.read()
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_read_timeout(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test read timeout raises TimeoutError."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
mock_sock.recv.side_effect = TimeoutError("Timed out")
|
||||
|
||||
transport.connect()
|
||||
|
||||
with pytest.raises(TimeoutError, match="Read timeout"):
|
||||
transport.read()
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_read_connection_closed(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test read when connection closed raises error."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
mock_sock.recv.return_value = b"" # Empty means connection closed
|
||||
|
||||
transport.connect()
|
||||
|
||||
with pytest.raises(ConnectionError, match="Connection closed"):
|
||||
transport.read()
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_query(self, mock_socket_class: Mock, transport: TCPTransport) -> None:
|
||||
"""Test query combines write and read."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
# Simulate receiving "Test Device\n"
|
||||
mock_sock.recv.side_effect = [
|
||||
b"T",
|
||||
b"e",
|
||||
b"s",
|
||||
b"t",
|
||||
b" ",
|
||||
b"D",
|
||||
b"e",
|
||||
b"v",
|
||||
b"i",
|
||||
b"c",
|
||||
b"e",
|
||||
b"\n",
|
||||
]
|
||||
|
||||
transport.connect()
|
||||
response = transport.query("*IDN?")
|
||||
|
||||
assert response == "Test Device"
|
||||
mock_sock.sendall.assert_called_once_with(b"*IDN?\n")
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_query_with_timeout(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test query with custom timeout."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
mock_sock.gettimeout.return_value = 1.0
|
||||
|
||||
mock_sock.recv.side_effect = [b"O", b"K", b"\n"]
|
||||
|
||||
transport.connect()
|
||||
response = transport.query("*IDN?", timeout=3.0)
|
||||
|
||||
assert response == "OK"
|
||||
mock_sock.settimeout.assert_any_call(3.0)
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_context_manager(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test context manager connects and disconnects."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
with transport:
|
||||
assert transport.is_connected
|
||||
|
||||
mock_sock.close.assert_called_once()
|
||||
assert not transport.is_connected
|
||||
|
||||
@patch("socket.socket")
|
||||
def test_context_manager_with_exception(
|
||||
self, mock_socket_class: Mock, transport: TCPTransport
|
||||
) -> None:
|
||||
"""Test context manager disconnects even on exception."""
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_class.return_value = mock_sock
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with transport:
|
||||
raise ValueError("Test error")
|
||||
|
||||
mock_sock.close.assert_called_once()
|
||||
assert not transport.is_connected
|
||||
Reference in New Issue
Block a user