Compare commits
19 Commits
v0.1.0-bet
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ba2ab9d5d8 | |||
| 64be5dacbf | |||
| a28752fc5b | |||
| 5152f85c8e | |||
| bd0071e88f | |||
| 400f97e9fb | |||
| cae52c1fa8 | |||
| 7c89cebf0b | |||
| 5d185815d0 | |||
| 9cf42112a6 | |||
| ed5362e712 | |||
| d1170b7db7 | |||
| 42356efce2 | |||
| 3fdaba500d | |||
| a0d096512f | |||
| 1f42098b6e | |||
| 7093446783 | |||
| 22be547e47 | |||
| 825af0b3bd |
@@ -72,7 +72,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Release
|
name: Release
|
||||||
needs: [lint, typecheck, test]
|
needs: [lint, typecheck, test]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -91,7 +91,22 @@ jobs:
|
|||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
run: |
|
||||||
with:
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
files: dist/*
|
VERSION=${TAG_NAME#v}
|
||||||
generate_release_notes: true
|
BODY=$(awk "/^## \[${VERSION}\]/{flag=1; next} /^## \\[/{flag=0} flag" CHANGELOG.md)
|
||||||
|
echo "Creating release ${TAG_NAME}"
|
||||||
|
RESPONSE=$(curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/json" -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"${TAG_NAME}\", \"body\": $(echo "$BODY" | jq -Rs .)}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
|
||||||
|
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
||||||
|
echo "Created release ID: ${RELEASE_ID}"
|
||||||
|
if [ "$RELEASE_ID" != "null" ] && [ -n "$RELEASE_ID" ]; then
|
||||||
|
for file in dist/*; do
|
||||||
|
echo "Uploading $(basename ${file})..."
|
||||||
|
curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -F "attachment=@${file}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename ${file})"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "Failed to create release: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-12-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Streamlit Dashboard Enhancement (Sprint 17)
|
||||||
|
- HAL-based instrument control (no direct physics access)
|
||||||
|
- Test execution page for running TempCo characterisation
|
||||||
|
- Results viewer page with filtering and historical data
|
||||||
|
- Form-based parameter controls preventing UI clunkiness
|
||||||
|
- Live simulation charts with auto-start
|
||||||
|
- End-to-end integration tests covering full workflow
|
||||||
|
- Updated README with installation and usage instructions
|
||||||
|
- Proprietary licence
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Integration tests now run 100x faster with simulation time scaling
|
||||||
|
- Removed confusing pause/clear chart buttons from dashboard
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CI release workflow now creates proper releases with changelog description
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Dashboard uses InstrumentFactory and InstrumentSet abstraction
|
||||||
|
- Embedded SimulationServer with threading synchronisation
|
||||||
|
- SQLite repository close() method for Windows file handle cleanup
|
||||||
|
- 259 unit tests, 12 integration tests all passing
|
||||||
|
- Coverage: 100% on core physics/instrument modules
|
||||||
|
|
||||||
## [0.1.0-beta.2] - 2025-12-03
|
## [0.1.0-beta.2] - 2025-12-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -128,7 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
| Version | Date | Milestone |
|
| Version | Date | Milestone |
|
||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| 0.1.0 | TBD | MVP Complete |
|
| 0.1.0 | 2025-12-04 | MVP Complete |
|
||||||
| 0.1.0-beta.2 | 2025-12-03 | First DVT test runs |
|
| 0.1.0-beta.2 | 2025-12-03 | First DVT test runs |
|
||||||
| 0.1.0-beta.1 | 2025-12-02 | HAL complete |
|
| 0.1.0-beta.1 | 2025-12-02 | HAL complete |
|
||||||
| 0.1.0-alpha.3 | 2025-12-02 | Network ready |
|
| 0.1.0-alpha.3 | 2025-12-02 | Network ready |
|
||||||
|
|||||||
5
LICENSE
Normal file
5
LICENSE
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Copyright (c) 2025 Kai Chappell. All rights reserved.
|
||||||
|
|
||||||
|
This software is proprietary and confidential. Unauthorized copying,
|
||||||
|
distribution, modification, or use of this software, via any medium,
|
||||||
|
is strictly prohibited without prior written permission from the author.
|
||||||
79
README.md
79
README.md
@@ -1,12 +1,12 @@
|
|||||||
# py_dvt_ate
|
# py_dvt_ate
|
||||||
|
|
||||||
**ThermalATE: Coupled Physics DVT Simulation Platform**
|
**Coupled Physics DVT Simulation Platform**
|
||||||
|
|
||||||
A software simulation environment that accurately models the physical coupling between thermal and electrical domains, enabling DVT (Design Validation Test) engineers to develop, validate, and debug characterisation test sequences without physical access to laboratory equipment.
|
A software simulation environment for offline development of ATE (Automated Test Equipment) characterisation algorithms. Accurately models thermal-electrical coupling, enabling DVT engineers to develop and validate test sequences without physical laboratory access.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
ThermalATE enables offline development of ATE (Automated Test Equipment) characterisation algorithms by simulating:
|
py_dvt_ate simulates a complete DVT test bench:
|
||||||
|
|
||||||
- **Thermal Chamber** - Temperature control with realistic ramp and settling behaviour
|
- **Thermal Chamber** - Temperature control with realistic ramp and settling behaviour
|
||||||
- **Programmable Power Supply** - Voltage/current control and measurement
|
- **Programmable Power Supply** - Voltage/current control and measurement
|
||||||
@@ -29,11 +29,78 @@ ThermalATE enables offline development of ATE (Automated Test Equipment) charact
|
|||||||
| [Technical Specification](docs/02_technical_specification.md) | Specifies **how** to implement the system |
|
| [Technical Specification](docs/02_technical_specification.md) | Specifies **how** to implement the system |
|
||||||
| [Architecture Decisions](docs/03_architecture_decisions.md) | Explains **why** decisions were made |
|
| [Architecture Decisions](docs/03_architecture_decisions.md) | Explains **why** decisions were made |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with development dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Interactive Dashboard
|
||||||
|
|
||||||
|
Launch the Streamlit dashboard to visualise the physics simulation and run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
py-dvt-ate dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens a browser window with:
|
||||||
|
- **Live Simulation** - Real-time temperature/voltage charts with physics coupling
|
||||||
|
- **Test Execution** - Run TempCo characterisation tests
|
||||||
|
- **Results Viewer** - Browse and analyse historical test results
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the simulation server (TCP ports for SCPI instruments)
|
||||||
|
py-dvt-ate serve
|
||||||
|
|
||||||
|
# List available tests
|
||||||
|
py-dvt-ate tests list
|
||||||
|
|
||||||
|
# Run a TempCo test
|
||||||
|
py-dvt-ate tests run tempco --config config/tempco_test.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from py_dvt_ate.instruments import InstrumentFactory
|
||||||
|
from py_dvt_ate.simulation import SimulationServer
|
||||||
|
|
||||||
|
# Start simulation server
|
||||||
|
server = SimulationServer()
|
||||||
|
server.start()
|
||||||
|
|
||||||
|
# Create instruments via HAL
|
||||||
|
factory = InstrumentFactory()
|
||||||
|
instruments = factory.create_from_config("config/default.yaml")
|
||||||
|
|
||||||
|
# Control instruments using standard interfaces
|
||||||
|
instruments.chamber.set_temperature(85.0)
|
||||||
|
instruments.psu.set_voltage(1, 5.0)
|
||||||
|
instruments.psu.enable_output(1, True)
|
||||||
|
voltage = instruments.dmm.measure_dc_voltage()
|
||||||
|
|
||||||
|
print(f"Output voltage: {voltage:.4f} V")
|
||||||
|
```
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Status:** In Development
|
**Status:** MVP Complete (v0.1.0)
|
||||||
|
|
||||||
This project is currently being developed. See the requirements document for the full scope and success criteria.
|
The core vertical slice is functional:
|
||||||
|
- Physics engine with thermal-electrical coupling
|
||||||
|
- Virtual instruments (chamber, PSU, DMM)
|
||||||
|
- Hardware Abstraction Layer
|
||||||
|
- SCPI-over-TCP server
|
||||||
|
- Test framework with TempCo test
|
||||||
|
- Streamlit dashboard
|
||||||
|
- SQLite/Parquet data persistence
|
||||||
|
|
||||||
|
See the requirements document for the full scope and future phases.
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
@@ -50,4 +117,4 @@ Kai Chappell
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
TBD
|
Proprietary - All rights reserved. See [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dev = [
|
|||||||
"ruff>=0.1",
|
"ruff>=0.1",
|
||||||
"mypy>=1.0",
|
"mypy>=1.0",
|
||||||
"types-PyYAML>=6.0",
|
"types-PyYAML>=6.0",
|
||||||
|
"pandas-stubs>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
|
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
|
||||||
|
|
||||||
__version__ = "0.1.0-beta.2"
|
__version__ = "0.1.0"
|
||||||
|
|||||||
@@ -83,5 +83,81 @@ def serve(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="list-tests")
|
||||||
|
def list_tests_cmd() -> None:
|
||||||
|
"""List all available DVT tests."""
|
||||||
|
from py_dvt_ate.app.test_commands import list_tests
|
||||||
|
|
||||||
|
list_tests()
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="run-test")
|
||||||
|
def run_test_cmd(
|
||||||
|
test_name: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="Name of the test to run (use list-tests to see available tests)."),
|
||||||
|
],
|
||||||
|
config_file: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--config", "-c", help="Path to configuration YAML file."),
|
||||||
|
] = None,
|
||||||
|
operator: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--operator", "-o", help="Operator identifier (e.g., email address)."),
|
||||||
|
] = None,
|
||||||
|
description: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--description", "-d", help="Test run description."),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a specific DVT test.
|
||||||
|
|
||||||
|
The test will connect to instruments based on the configuration file
|
||||||
|
(default: config/default.yaml). Results are stored in the data directory.
|
||||||
|
"""
|
||||||
|
from py_dvt_ate.app.test_commands import run_test
|
||||||
|
|
||||||
|
run_test(
|
||||||
|
test_name=test_name,
|
||||||
|
config_file=config_file,
|
||||||
|
operator=operator,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command(name="query")
|
||||||
|
def query_cmd(
|
||||||
|
instrument: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="Instrument to query (chamber, psu, or dmm)."),
|
||||||
|
],
|
||||||
|
command: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Argument(help="SCPI command to send (e.g., *IDN?, TEMP:SETPOINT?)."),
|
||||||
|
],
|
||||||
|
config_file: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--config", "-c", help="Path to configuration YAML file."),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a SCPI command to an instrument and print the response.
|
||||||
|
|
||||||
|
Useful for debugging and manual instrument control. Connect to
|
||||||
|
instruments based on configuration and send raw SCPI commands.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
py-dvt-ate query chamber "*IDN?"
|
||||||
|
py-dvt-ate query psu "VOLT? 1"
|
||||||
|
py-dvt-ate query dmm "MEAS:VOLT:DC?"
|
||||||
|
"""
|
||||||
|
from py_dvt_ate.app.instrument_commands import query_instrument
|
||||||
|
|
||||||
|
query_instrument(
|
||||||
|
instrument=instrument,
|
||||||
|
command=command,
|
||||||
|
config_file=config_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
68
src/py_dvt_ate/app/instrument_commands.py
Normal file
68
src/py_dvt_ate/app/instrument_commands.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Instrument query commands for CLI."""
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from py_dvt_ate.app.config import load_config
|
||||||
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
|
|
||||||
|
|
||||||
|
def query_instrument(
|
||||||
|
instrument: str,
|
||||||
|
command: str,
|
||||||
|
config_file: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a SCPI command to an instrument and print the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instrument: Instrument to query (chamber, psu, or dmm).
|
||||||
|
command: SCPI command to send.
|
||||||
|
config_file: Path to configuration YAML file.
|
||||||
|
"""
|
||||||
|
# Load configuration
|
||||||
|
config_path = config_file or "config/default.yaml"
|
||||||
|
try:
|
||||||
|
config = load_config(config_path)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
typer.echo(f"Error: Configuration file not found: {config_path}", err=True)
|
||||||
|
raise typer.Exit(code=1) from err
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error loading configuration: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create instruments
|
||||||
|
try:
|
||||||
|
# Convert AppConfig to InstrumentConfig
|
||||||
|
inst_config = InstrumentConfig(
|
||||||
|
backend=config.instruments.backend,
|
||||||
|
simulator_host=config.instruments.simulator.host,
|
||||||
|
chamber_port=config.instruments.simulator.thermal_chamber_port,
|
||||||
|
psu_port=config.instruments.simulator.power_supply_port,
|
||||||
|
dmm_port=config.instruments.simulator.multimeter_port,
|
||||||
|
chamber_visa=config.instruments.pyvisa.thermal_chamber,
|
||||||
|
psu_visa=config.instruments.pyvisa.power_supply,
|
||||||
|
dmm_visa=config.instruments.pyvisa.multimeter,
|
||||||
|
)
|
||||||
|
instruments = InstrumentFactory.create(inst_config)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error connecting to instruments: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Send command to the specified instrument
|
||||||
|
try:
|
||||||
|
# Access the transport layer to send raw commands
|
||||||
|
if instrument == "chamber":
|
||||||
|
response = instruments.chamber._transport.query(command) # type: ignore[attr-defined]
|
||||||
|
elif instrument == "psu":
|
||||||
|
response = instruments.psu._transport.query(command) # type: ignore[attr-defined]
|
||||||
|
elif instrument == "dmm":
|
||||||
|
response = instruments.dmm._transport.query(command) # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
typer.echo(f"Error: Unknown instrument '{instrument}'", err=True)
|
||||||
|
typer.echo("Valid instruments: chamber, psu, dmm", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
typer.echo(response)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error sending command: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
175
src/py_dvt_ate/app/test_commands.py
Normal file
175
src/py_dvt_ate/app/test_commands.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Test execution commands for CLI."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from py_dvt_ate.app.config import load_config
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
from py_dvt_ate.framework.context import ITest
|
||||||
|
from py_dvt_ate.framework.runner import TestRunner
|
||||||
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_tests() -> dict[str, type]:
|
||||||
|
"""Discover all available tests by scanning the tests package.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping test names to test classes.
|
||||||
|
"""
|
||||||
|
tests: dict[str, type] = {}
|
||||||
|
|
||||||
|
# Find the tests package directory
|
||||||
|
import py_dvt_ate.tests
|
||||||
|
|
||||||
|
tests_pkg_path = Path(py_dvt_ate.tests.__file__).parent
|
||||||
|
|
||||||
|
# Scan all Python files in the tests package
|
||||||
|
for py_file in tests_pkg_path.rglob("*.py"):
|
||||||
|
if py_file.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert file path to module name
|
||||||
|
rel_path = py_file.relative_to(tests_pkg_path.parent)
|
||||||
|
module_name = "py_dvt_ate." + str(rel_path.with_suffix("")).replace("/", ".").replace("\\", ".")
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
|
||||||
|
# Find all classes that implement ITest
|
||||||
|
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
||||||
|
if (
|
||||||
|
obj is not ITest
|
||||||
|
and issubclass(obj, ITest)
|
||||||
|
and not inspect.isabstract(obj)
|
||||||
|
and hasattr(obj, "name")
|
||||||
|
):
|
||||||
|
# Create instance to get the name property
|
||||||
|
instance = obj()
|
||||||
|
tests[instance.name] = obj
|
||||||
|
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return tests
|
||||||
|
|
||||||
|
|
||||||
|
def list_tests() -> None:
|
||||||
|
"""List all available DVT tests."""
|
||||||
|
tests = _discover_tests()
|
||||||
|
|
||||||
|
if not tests:
|
||||||
|
typer.echo("No tests found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
typer.echo("Available DVT tests:")
|
||||||
|
typer.echo("")
|
||||||
|
|
||||||
|
for test_name in sorted(tests.keys()):
|
||||||
|
test_class = tests[test_name]
|
||||||
|
instance = test_class()
|
||||||
|
typer.echo(f" {test_name:15s} {instance.description}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_test(
|
||||||
|
test_name: str,
|
||||||
|
config_file: str | None = None,
|
||||||
|
operator: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a specific DVT test.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_name: Name of the test to run.
|
||||||
|
config_file: Path to configuration YAML file.
|
||||||
|
operator: Operator identifier (e.g., email address).
|
||||||
|
description: Test run description.
|
||||||
|
"""
|
||||||
|
# Discover available tests
|
||||||
|
tests = _discover_tests()
|
||||||
|
|
||||||
|
if test_name not in tests:
|
||||||
|
typer.echo(f"Error: Test \'{test_name}\' not found.", err=True)
|
||||||
|
typer.echo("", err=True)
|
||||||
|
typer.echo("Available tests:", err=True)
|
||||||
|
for name in sorted(tests.keys()):
|
||||||
|
typer.echo(f" - {name}", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
config_path = config_file or "config/default.yaml"
|
||||||
|
try:
|
||||||
|
config = load_config(config_path)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
typer.echo(f"Error: Configuration file not found: {config_path}", err=True)
|
||||||
|
typer.echo("Run with --config to specify a different config file.", err=True)
|
||||||
|
raise typer.Exit(code=1) from err
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error loading configuration: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create repository
|
||||||
|
try:
|
||||||
|
repository = SQLiteRepository(config.data.database_path)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error initialising repository: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create instruments
|
||||||
|
typer.echo(f"Connecting to instruments ({config.instruments.backend})...")
|
||||||
|
try:
|
||||||
|
# Convert AppConfig to InstrumentConfig
|
||||||
|
inst_config = InstrumentConfig(
|
||||||
|
backend=config.instruments.backend,
|
||||||
|
simulator_host=config.instruments.simulator.host,
|
||||||
|
chamber_port=config.instruments.simulator.thermal_chamber_port,
|
||||||
|
psu_port=config.instruments.simulator.power_supply_port,
|
||||||
|
dmm_port=config.instruments.simulator.multimeter_port,
|
||||||
|
chamber_visa=config.instruments.pyvisa.thermal_chamber,
|
||||||
|
psu_visa=config.instruments.pyvisa.power_supply,
|
||||||
|
dmm_visa=config.instruments.pyvisa.multimeter,
|
||||||
|
)
|
||||||
|
instruments = InstrumentFactory.create(inst_config)
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error connecting to instruments: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
|
|
||||||
|
# Create test instance
|
||||||
|
test_class = tests[test_name]
|
||||||
|
test = test_class()
|
||||||
|
|
||||||
|
# Run test
|
||||||
|
typer.echo(f"Running test: {test.name}")
|
||||||
|
typer.echo(f"Description: {test.description}")
|
||||||
|
typer.echo("")
|
||||||
|
|
||||||
|
try:
|
||||||
|
runner = TestRunner(repository)
|
||||||
|
run_id = runner.run_test(
|
||||||
|
test=test,
|
||||||
|
instruments=instruments,
|
||||||
|
operator=operator,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve final status
|
||||||
|
run = repository.get_run(run_id)
|
||||||
|
typer.echo("")
|
||||||
|
typer.echo(f"Test completed: {run.status.value}")
|
||||||
|
typer.echo(f"Run ID: {run_id}")
|
||||||
|
|
||||||
|
# Exit with appropriate code
|
||||||
|
if run.status.value == "PASSED":
|
||||||
|
raise typer.Exit(code=0)
|
||||||
|
else:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
typer.echo("")
|
||||||
|
typer.echo("Test interrupted by user.")
|
||||||
|
raise typer.Exit(code=130) from None
|
||||||
|
except Exception as e:
|
||||||
|
typer.echo(f"Error running test: {e}", err=True)
|
||||||
|
raise typer.Exit(code=1) from e
|
||||||
@@ -70,6 +70,13 @@ class ITestRepository(ABC):
|
|||||||
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
|
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
|
||||||
"""Retrieve measurements as pandas DataFrame."""
|
"""Retrieve measurements as pandas DataFrame."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_all_runs(self) -> list[TestRun]:
|
||||||
|
"""Retrieve all test runs, ordered by started_at descending."""
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close repository and release resources. Optional to implement."""
|
||||||
|
|
||||||
|
|
||||||
class SQLiteRepository(ITestRepository):
|
class SQLiteRepository(ITestRepository):
|
||||||
"""SQLite-based repository for test data.
|
"""SQLite-based repository for test data.
|
||||||
@@ -357,3 +364,52 @@ class SQLiteRepository(ITestRepository):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return pd.read_parquet(parquet_path)
|
return pd.read_parquet(parquet_path)
|
||||||
|
|
||||||
|
def get_all_runs(self) -> list[TestRun]:
|
||||||
|
"""Retrieve all test runs, ordered by started_at descending.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all TestRun objects, newest first.
|
||||||
|
"""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, test_name, started_at, status, config_json,
|
||||||
|
description, completed_at, operator, notes, created_at
|
||||||
|
FROM test_runs
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
TestRun(
|
||||||
|
id=row["id"],
|
||||||
|
test_name=row["test_name"],
|
||||||
|
started_at=datetime.fromisoformat(row["started_at"]),
|
||||||
|
status=TestStatus(row["status"]),
|
||||||
|
config_json=row["config_json"],
|
||||||
|
description=row["description"],
|
||||||
|
completed_at=(
|
||||||
|
datetime.fromisoformat(row["completed_at"])
|
||||||
|
if row["completed_at"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
operator=row["operator"],
|
||||||
|
notes=row["notes"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close repository and release resources.
|
||||||
|
|
||||||
|
SQLite connections are managed via context managers and auto-close.
|
||||||
|
This method performs explicit cleanup for Windows file handle issues.
|
||||||
|
"""
|
||||||
|
# Force garbage collection to release any lingering connections
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
|||||||
@@ -216,7 +216,9 @@ class InstrumentServer:
|
|||||||
# Process command through instrument
|
# Process command through instrument
|
||||||
response = instrument.process(command)
|
response = instrument.process(command)
|
||||||
|
|
||||||
# Send response with newline terminator
|
# Send response with newline terminator (only if non-empty)
|
||||||
|
# Per SCPI protocol: commands that complete successfully without
|
||||||
|
# output do not send a response. Only queries and errors respond.
|
||||||
if response:
|
if response:
|
||||||
writer.write(f"{response}\n".encode())
|
writer.write(f"{response}\n".encode())
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
|||||||
@@ -57,17 +57,39 @@ class SimulationServer:
|
|||||||
self._instrument_server: InstrumentServer | None = None
|
self._instrument_server: InstrumentServer | None = None
|
||||||
self._physics_task: asyncio.Task[None] | None = None
|
self._physics_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._paused = False # Pause physics simulation
|
||||||
|
self._time_scale = 1.0 # Simulation time multiplier
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if server is currently running."""
|
"""Check if server is currently running."""
|
||||||
return self._running
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self) -> bool:
|
||||||
|
"""Check if physics simulation is paused."""
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
@paused.setter
|
||||||
|
def paused(self, value: bool) -> None:
|
||||||
|
"""Pause or resume the physics simulation."""
|
||||||
|
self._paused = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def physics_engine(self) -> PhysicsEngine | None:
|
def physics_engine(self) -> PhysicsEngine | None:
|
||||||
"""Get the physics engine instance."""
|
"""Get the physics engine instance."""
|
||||||
return self._physics_engine
|
return self._physics_engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_scale(self) -> float:
|
||||||
|
"""Get the current time scale multiplier."""
|
||||||
|
return self._time_scale
|
||||||
|
|
||||||
|
@time_scale.setter
|
||||||
|
def time_scale(self, value: float) -> None:
|
||||||
|
"""Set the time scale multiplier (e.g., 10.0 = 10x faster)."""
|
||||||
|
self._time_scale = max(0.1, min(value, 1000.0))
|
||||||
|
|
||||||
def _setup(self) -> None:
|
def _setup(self) -> None:
|
||||||
"""Create and wire up all components."""
|
"""Create and wire up all components."""
|
||||||
# Create physics engine
|
# Create physics engine
|
||||||
@@ -101,8 +123,12 @@ class SimulationServer:
|
|||||||
dt = self._physics_engine.dt
|
dt = self._physics_engine.dt
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
self._physics_engine.step()
|
if not self._paused:
|
||||||
# Sleep for the physics timestep
|
# 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)
|
await asyncio.sleep(dt)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
TEMP:SETPOINT? - Query current setpoint
|
TEMP:SETPOINT? - Query current setpoint
|
||||||
TEMP:ACTUAL? - Query actual chamber temperature
|
TEMP:ACTUAL? - Query actual chamber temperature
|
||||||
TEMP:STAB? - Query temperature stability (1=stable, 0=settling)
|
TEMP:STAB? - Query temperature stability (1=stable, 0=settling)
|
||||||
|
TEMP:RAMP <value> - Set temperature ramp rate in degrees C/min
|
||||||
|
TEMP:RAMP? - Query current ramp rate
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
manufacturer: "PyDVTATE"
|
manufacturer: "PyDVTATE"
|
||||||
@@ -47,6 +49,7 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
physics_engine: Reference to physics engine for temperature state.
|
physics_engine: Reference to physics engine for temperature state.
|
||||||
"""
|
"""
|
||||||
self._setpoint = 25.0 # Default setpoint
|
self._setpoint = 25.0 # Default setpoint
|
||||||
|
self._ramp_rate = 10.0 # Default ramp rate in degrees C/min
|
||||||
super().__init__(physics_engine)
|
super().__init__(physics_engine)
|
||||||
|
|
||||||
def _setup_commands(self) -> None:
|
def _setup_commands(self) -> None:
|
||||||
@@ -54,10 +57,12 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
self.register_command("TEMP:SETPOINT", self._handle_temp_setpoint)
|
self.register_command("TEMP:SETPOINT", self._handle_temp_setpoint)
|
||||||
self.register_command("TEMP:ACTUAL", self._handle_temp_actual)
|
self.register_command("TEMP:ACTUAL", self._handle_temp_actual)
|
||||||
self.register_command("TEMP:STAB", self._handle_temp_stab)
|
self.register_command("TEMP:STAB", self._handle_temp_stab)
|
||||||
|
self.register_command("TEMP:RAMP", self._handle_temp_ramp)
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""Reset chamber to default state."""
|
"""Reset chamber to default state."""
|
||||||
self._setpoint = 25.0
|
self._setpoint = 25.0
|
||||||
|
self._ramp_rate = 10.0
|
||||||
if self._physics_engine is not None:
|
if self._physics_engine is not None:
|
||||||
self._physics_engine.set_chamber_setpoint(self._setpoint)
|
self._physics_engine.set_chamber_setpoint(self._setpoint)
|
||||||
|
|
||||||
@@ -141,3 +146,36 @@ class ThermalChamberSim(BaseInstrument):
|
|||||||
if error <= self.STABILITY_THRESHOLD:
|
if error <= self.STABILITY_THRESHOLD:
|
||||||
return "1"
|
return "1"
|
||||||
return "0"
|
return "0"
|
||||||
|
|
||||||
|
def _handle_temp_ramp(self, command: SCPICommand) -> str:
|
||||||
|
"""Handle TEMP:RAMP command/query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Parsed SCPI command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ramp rate value for query, empty string for set command.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ramp rate argument is invalid.
|
||||||
|
"""
|
||||||
|
if command.is_query:
|
||||||
|
return f"{self._ramp_rate:.2f}"
|
||||||
|
|
||||||
|
# Set command requires one argument
|
||||||
|
if not command.arguments:
|
||||||
|
raise ValueError("TEMP:RAMP requires a value")
|
||||||
|
|
||||||
|
try:
|
||||||
|
ramp_rate = float(command.arguments[0])
|
||||||
|
except ValueError as err:
|
||||||
|
raise ValueError(f"Invalid ramp rate value: {command.arguments[0]}") from err
|
||||||
|
|
||||||
|
if ramp_rate <= 0:
|
||||||
|
raise ValueError("Ramp rate must be positive")
|
||||||
|
|
||||||
|
self._ramp_rate = ramp_rate
|
||||||
|
# Note: Simulator doesn't currently model ramp rate dynamics
|
||||||
|
# The value is stored but not used in physics calculations
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This test characterises the output voltage temperature coefficient by
|
This test characterises the output voltage temperature coefficient by
|
||||||
sweeping the chamber temperature and measuring output voltage at each point.
|
sweeping the chamber temperature and measuring output voltage at each point.
|
||||||
The TempCo is calculated from the linear regression slope and expressed
|
The TempCo is calculated from the linear regression slope and expressed
|
||||||
in parts per million per degree Celsius (ppm/°C).
|
in parts per million per degree Celsius (ppm/C).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from py_dvt_ate.data.models import TestStatus
|
from py_dvt_ate.data.models import TestStatus
|
||||||
@@ -29,12 +29,12 @@ class TempCoTest(BaseDVTTest):
|
|||||||
5. Evaluate against specification limits
|
5. Evaluate against specification limits
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
temperatures: List of temperature points (°C). Default: [-40, -20, 0, 25, 50, 85]
|
temperatures: List of temperature points (C). Default: [-40, -20, 0, 25, 50, 85]
|
||||||
input_voltage: DUT input voltage (V). Default: 5.0
|
input_voltage: DUT input voltage (V). Default: 5.0
|
||||||
load_current: DUT load current (A). Default: 0.1
|
load_current: DUT load current (A). Default: 0.1
|
||||||
settle_time: Additional settling time at each temp (s). Default: 5.0
|
settle_time: Additional settling time at each temp (s). Default: 5.0
|
||||||
num_samples: Number of measurements to average per point. Default: 5
|
num_samples: Number of measurements to average per point. Default: 5
|
||||||
tempco_limit: Maximum allowed TempCo magnitude (ppm/°C). Default: ±50.0
|
tempco_limit: Maximum allowed TempCo magnitude (ppm/C). Default: +/-50.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -90,7 +90,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
# Temperature sweep
|
# Temperature sweep
|
||||||
for temp_setpoint in temperatures:
|
for temp_setpoint in temperatures:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Temperature point: {temp_setpoint}°C",
|
f"Temperature point: {temp_setpoint}C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
)
|
)
|
||||||
if not stable:
|
if not stable:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Warning: Temperature did not stabilise at {temp_setpoint}°C",
|
f"Warning: Temperature did not stabilise at {temp_setpoint}C",
|
||||||
level="WARNING",
|
level="WARNING",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,8 +133,8 @@ class TempCoTest(BaseDVTTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Measured Vout = {vout_mean:.6f}V ± {vout_std * 1e6:.1f}μV "
|
f"Measured Vout = {vout_mean:.6f}V +/- {vout_std * 1e6:.1f}uV "
|
||||||
f"at T={actual_temp:.2f}°C",
|
f"at T={actual_temp:.2f}C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
tempco_ppm = self._calculate_tempco(temp_points, vout_points)
|
tempco_ppm = self._calculate_tempco(temp_points, vout_points)
|
||||||
|
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"Calculated TempCo = {tempco_ppm:.2f} ppm/°C",
|
f"Calculated TempCo = {tempco_ppm:.2f} ppm/C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ class TempCoTest(BaseDVTTest):
|
|||||||
context.logger.log_result(
|
context.logger.log_result(
|
||||||
parameter="temp_co",
|
parameter="temp_co",
|
||||||
value=tempco_ppm,
|
value=tempco_ppm,
|
||||||
unit="ppm/°C",
|
unit="ppm/C",
|
||||||
lower_limit=-abs(tempco_limit),
|
lower_limit=-abs(tempco_limit),
|
||||||
upper_limit=abs(tempco_limit),
|
upper_limit=abs(tempco_limit),
|
||||||
)
|
)
|
||||||
@@ -164,13 +164,13 @@ class TempCoTest(BaseDVTTest):
|
|||||||
|
|
||||||
if passed:
|
if passed:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"TempCo test PASSED: {tempco_ppm:.2f} ppm/°C within ±{tempco_limit} ppm/°C",
|
f"TempCo test PASSED: {tempco_ppm:.2f} ppm/C within +/-{tempco_limit} ppm/C",
|
||||||
level="INFO",
|
level="INFO",
|
||||||
)
|
)
|
||||||
return TestStatus.PASSED
|
return TestStatus.PASSED
|
||||||
else:
|
else:
|
||||||
context.logger.log_event(
|
context.logger.log_event(
|
||||||
f"TempCo test FAILED: {tempco_ppm:.2f} ppm/°C exceeds ±{tempco_limit} ppm/°C",
|
f"TempCo test FAILED: {tempco_ppm:.2f} ppm/C exceeds +/-{tempco_limit} ppm/C",
|
||||||
level="ERROR",
|
level="ERROR",
|
||||||
)
|
)
|
||||||
return TestStatus.FAILED
|
return TestStatus.FAILED
|
||||||
@@ -198,14 +198,14 @@ class TempCoTest(BaseDVTTest):
|
|||||||
"""Calculate temperature coefficient from measurements.
|
"""Calculate temperature coefficient from measurements.
|
||||||
|
|
||||||
Uses linear regression to find the slope (dV/dT), then converts
|
Uses linear regression to find the slope (dV/dT), then converts
|
||||||
to ppm/°C relative to the nominal voltage (voltage at median temperature).
|
to ppm/C relative to the nominal voltage (voltage at median temperature).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
temperatures: Temperature measurements in °C.
|
temperatures: Temperature measurements in C.
|
||||||
voltages: Output voltage measurements in V.
|
voltages: Output voltage measurements in V.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Temperature coefficient in ppm/°C.
|
Temperature coefficient in ppm/C.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If insufficient data points.
|
ValueError: If insufficient data points.
|
||||||
@@ -230,14 +230,14 @@ class TempCoTest(BaseDVTTest):
|
|||||||
if var_t == 0:
|
if var_t == 0:
|
||||||
raise ValueError("Temperature variance is zero (all temps identical)")
|
raise ValueError("Temperature variance is zero (all temps identical)")
|
||||||
|
|
||||||
slope = cov / var_t # dV/dT in V/°C
|
slope = cov / var_t # dV/dT in V/C
|
||||||
|
|
||||||
# Find nominal voltage (voltage at median temperature)
|
# Find nominal voltage (voltage at median temperature)
|
||||||
sorted_pairs = sorted(zip(temperatures, voltages, strict=True))
|
sorted_pairs = sorted(zip(temperatures, voltages, strict=True))
|
||||||
mid_idx = len(sorted_pairs) // 2
|
mid_idx = len(sorted_pairs) // 2
|
||||||
v_nominal = sorted_pairs[mid_idx][1]
|
v_nominal = sorted_pairs[mid_idx][1]
|
||||||
|
|
||||||
# Convert to ppm/°C: (dV/dT) / V_nom * 10^6
|
# Convert to ppm/C: (dV/dT) / V_nom * 10^6
|
||||||
tempco_ppm = (slope / v_nominal) * 1e6
|
tempco_ppm = (slope / v_nominal) * 1e6
|
||||||
|
|
||||||
return tempco_ppm
|
return tempco_ppm
|
||||||
|
|||||||
@@ -1 +1,135 @@
|
|||||||
"""Configuration for integration tests."""
|
"""Configuration for integration tests."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
||||||
|
|
||||||
|
|
||||||
|
class ServerThread:
|
||||||
|
"""Helper class to run SimulationServer in a background thread.
|
||||||
|
|
||||||
|
This allows synchronous test code to run in the main thread while
|
||||||
|
the async server runs in its own thread with its own event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ServerConfig):
|
||||||
|
"""Initialise the server thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Server configuration.
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.server: SimulationServer | None = None
|
||||||
|
self.thread: threading.Thread | None = None
|
||||||
|
self.loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._started = threading.Event()
|
||||||
|
self._error: Exception | None = None
|
||||||
|
|
||||||
|
def _run_server(self) -> None:
|
||||||
|
"""Run the server in the background thread."""
|
||||||
|
try:
|
||||||
|
# Create new event loop for this thread
|
||||||
|
self.loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self.loop)
|
||||||
|
|
||||||
|
# Create and start server
|
||||||
|
self.server = SimulationServer(self.config)
|
||||||
|
self.loop.run_until_complete(self.server.start())
|
||||||
|
|
||||||
|
# Signal that server is started
|
||||||
|
self._started.set()
|
||||||
|
|
||||||
|
# Run event loop until stopped
|
||||||
|
self.loop.run_forever()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._error = e
|
||||||
|
self._started.set() # Unblock waiting thread even on error
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
if self.server is not None and self.loop is not None:
|
||||||
|
try:
|
||||||
|
self.loop.run_until_complete(self.server.stop())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.loop is not None:
|
||||||
|
self.loop.close()
|
||||||
|
|
||||||
|
def start(self, timeout: float = 5.0) -> None:
|
||||||
|
"""Start the server thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for server to start (seconds).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If server fails to start within timeout.
|
||||||
|
Exception: If server raises an exception during startup.
|
||||||
|
"""
|
||||||
|
self.thread = threading.Thread(target=self._run_server, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
# Wait for server to start
|
||||||
|
if not self._started.wait(timeout=timeout):
|
||||||
|
raise RuntimeError("Server failed to start within timeout")
|
||||||
|
|
||||||
|
# Check if there was an error during startup
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
# Give server a bit more time to fully initialize
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0) -> None:
|
||||||
|
"""Stop the server thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for server to stop (seconds).
|
||||||
|
"""
|
||||||
|
if self.loop is not None and self.loop.is_running():
|
||||||
|
# Schedule stop in the server's event loop
|
||||||
|
self.loop.call_soon_threadsafe(self.loop.stop)
|
||||||
|
|
||||||
|
if self.thread is not None:
|
||||||
|
self.thread.join(timeout=timeout)
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def simulation_server() -> Generator[ServerConfig, None, None]:
|
||||||
|
"""Provide a running simulation server for integration tests.
|
||||||
|
|
||||||
|
The server runs in a background thread with its own event loop,
|
||||||
|
allowing synchronous test code to run in the main thread.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
ServerConfig with connection details for the running server.
|
||||||
|
"""
|
||||||
|
# Use unique ports for each test to avoid conflicts
|
||||||
|
import random
|
||||||
|
|
||||||
|
base_port = random.randint(20000, 30000)
|
||||||
|
|
||||||
|
config = ServerConfig(
|
||||||
|
host="127.0.0.1",
|
||||||
|
chamber_port=base_port,
|
||||||
|
psu_port=base_port + 1,
|
||||||
|
dmm_port=base_port + 2,
|
||||||
|
physics_rate_hz=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_thread = ServerThread(config)
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# Speed up simulation for tests (100x faster)
|
||||||
|
if server_thread.server is not None:
|
||||||
|
server_thread.server.time_scale = 100.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield config
|
||||||
|
finally:
|
||||||
|
server_thread.stop()
|
||||||
|
|||||||
289
tests/integration/test_e2e.py
Normal file
289
tests/integration/test_e2e.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""End-to-end integration tests for py_dvt_ate.
|
||||||
|
|
||||||
|
This module contains comprehensive tests that exercise the entire system:
|
||||||
|
- Simulation server startup
|
||||||
|
- Instrument connectivity via HAL
|
||||||
|
- Test execution through the framework
|
||||||
|
- Data persistence
|
||||||
|
- Results retrieval
|
||||||
|
|
||||||
|
These tests verify that all components work together correctly in a
|
||||||
|
complete workflow from server start to results analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from py_dvt_ate.data.models import TestStatus
|
||||||
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
|
from py_dvt_ate.framework.runner import TestRunner
|
||||||
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
|
from py_dvt_ate.simulation.server import ServerConfig
|
||||||
|
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_tempco_characterization(simulation_server: ServerConfig) -> None:
|
||||||
|
"""End-to-end test: Run complete TempCo characterization workflow.
|
||||||
|
|
||||||
|
This test exercises the entire system:
|
||||||
|
1. Simulation server is running (from fixture)
|
||||||
|
2. Create instruments via HAL factory
|
||||||
|
3. Create test repository and runner
|
||||||
|
4. Execute TempCo test
|
||||||
|
5. Verify results are persisted
|
||||||
|
6. Verify measurements are stored
|
||||||
|
7. Retrieve and analyze results
|
||||||
|
|
||||||
|
This is the closest test to real-world usage, verifying that all
|
||||||
|
components integrate correctly.
|
||||||
|
"""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
# Step 1: Create instruments via HAL
|
||||||
|
instrument_config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
|
|
||||||
|
# Connect to instruments
|
||||||
|
instruments.chamber.transport.connect()
|
||||||
|
instruments.psu.transport.connect()
|
||||||
|
instruments.dmm.transport.connect()
|
||||||
|
|
||||||
|
# Verify instrument connectivity
|
||||||
|
idn = instruments.chamber.get_temperature() # Should not raise
|
||||||
|
assert isinstance(idn, float)
|
||||||
|
|
||||||
|
# Step 2: Create repository and test runner
|
||||||
|
db_path = Path(tmpdir) / "test.db"
|
||||||
|
repository = SQLiteRepository(str(db_path))
|
||||||
|
runner = TestRunner(repository)
|
||||||
|
|
||||||
|
# Step 3: Execute TempCo test with minimal config for speed
|
||||||
|
test = TempCoTest()
|
||||||
|
config = {
|
||||||
|
"temperatures": [0.0, 25.0, 50.0], # Reduced for test speed
|
||||||
|
"input_voltage": 5.0,
|
||||||
|
"load_current": 0.1,
|
||||||
|
"settle_time": 0.5, # Reduced for test speed
|
||||||
|
"num_samples": 3, # Reduced for test speed
|
||||||
|
"tempco_limit": 100.0, # Relaxed for test
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id = runner.run_test(
|
||||||
|
test=test,
|
||||||
|
instruments=instruments,
|
||||||
|
config=config,
|
||||||
|
operator="test_user",
|
||||||
|
description="E2E integration test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Verify run was created
|
||||||
|
assert run_id is not None
|
||||||
|
run = repository.get_run(run_id)
|
||||||
|
assert run.test_name == "tempco"
|
||||||
|
assert run.status in [TestStatus.PASSED, TestStatus.FAILED] # Either is valid
|
||||||
|
assert run.completed_at is not None
|
||||||
|
assert run.operator == "test_user"
|
||||||
|
|
||||||
|
# Step 5: Verify results were stored
|
||||||
|
results = repository.get_results(run_id)
|
||||||
|
assert len(results) > 0
|
||||||
|
|
||||||
|
# Should have TempCo result
|
||||||
|
tempco_result = next((r for r in results if r.parameter == "temp_co"), None)
|
||||||
|
assert tempco_result is not None
|
||||||
|
assert tempco_result.unit == "ppm/C"
|
||||||
|
assert tempco_result.lower_limit is not None
|
||||||
|
assert tempco_result.upper_limit is not None
|
||||||
|
|
||||||
|
# Step 6: Verify measurements were stored
|
||||||
|
measurements_df = repository.get_measurements_dataframe(run_id)
|
||||||
|
assert measurements_df is not None
|
||||||
|
assert not measurements_df.empty
|
||||||
|
|
||||||
|
# Should have v_out measurements
|
||||||
|
v_out_measurements = measurements_df[measurements_df["parameter"] == "v_out"]
|
||||||
|
assert len(v_out_measurements) >= 3 # At least one per temperature point
|
||||||
|
|
||||||
|
# Step 7: Verify data integrity
|
||||||
|
# All measurements should have valid values
|
||||||
|
assert (measurements_df["value"] > 0).all()
|
||||||
|
# All measurements should have units
|
||||||
|
assert measurements_df["unit"].notna().all()
|
||||||
|
# Timestamps should be monotonically increasing
|
||||||
|
assert measurements_df["timestamp"].is_monotonic_increasing
|
||||||
|
|
||||||
|
# Cleanup: close repository before tempdir cleanup (Windows file locking)
|
||||||
|
repository.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_server_lifecycle(simulation_server: ServerConfig) -> None:
|
||||||
|
"""Test simulation server lifecycle management.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Server starts successfully
|
||||||
|
- Physics engine is running
|
||||||
|
- Multiple instruments can connect
|
||||||
|
- Server can be stopped cleanly
|
||||||
|
"""
|
||||||
|
# Server is already running from fixture (in background thread)
|
||||||
|
# We verify it works by connecting instruments
|
||||||
|
|
||||||
|
# Multiple instruments should be able to connect
|
||||||
|
config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments1 = InstrumentFactory.create(config)
|
||||||
|
instruments2 = InstrumentFactory.create(config)
|
||||||
|
|
||||||
|
# Connect instruments
|
||||||
|
instruments1.chamber.transport.connect()
|
||||||
|
instruments2.chamber.transport.connect()
|
||||||
|
|
||||||
|
# Both should work independently
|
||||||
|
temp1 = instruments1.chamber.get_temperature()
|
||||||
|
temp2 = instruments2.chamber.get_temperature()
|
||||||
|
|
||||||
|
assert isinstance(temp1, float)
|
||||||
|
assert isinstance(temp2, float)
|
||||||
|
# Both should read similar values (same simulation)
|
||||||
|
assert abs(temp1 - temp2) < 1.0 # Within 1 degree
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_instrument_hal_abstraction(simulation_server: ServerConfig) -> None:
|
||||||
|
"""Test Hardware Abstraction Layer works correctly.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Instruments implement HAL interfaces
|
||||||
|
- Commands work through HAL
|
||||||
|
- State changes propagate through physics
|
||||||
|
"""
|
||||||
|
config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments = InstrumentFactory.create(config)
|
||||||
|
|
||||||
|
# Connect instruments
|
||||||
|
instruments.chamber.transport.connect()
|
||||||
|
instruments.psu.transport.connect()
|
||||||
|
instruments.dmm.transport.connect()
|
||||||
|
|
||||||
|
# Test thermal chamber HAL
|
||||||
|
instruments.chamber.set_temperature(30.0)
|
||||||
|
setpoint = instruments.chamber.get_setpoint()
|
||||||
|
assert setpoint == 30.0
|
||||||
|
|
||||||
|
# Test power supply HAL
|
||||||
|
instruments.psu.set_voltage(1, 5.0)
|
||||||
|
instruments.psu.set_current_limit(1, 0.5)
|
||||||
|
instruments.psu.enable_output(1, True)
|
||||||
|
|
||||||
|
voltage_setpoint = instruments.psu.get_voltage(1)
|
||||||
|
assert voltage_setpoint == 5.0
|
||||||
|
|
||||||
|
enabled = instruments.psu.is_output_enabled(1)
|
||||||
|
assert enabled is True
|
||||||
|
|
||||||
|
# Wait a moment for physics to update
|
||||||
|
import time
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Measure voltage with DMM
|
||||||
|
measured_voltage = instruments.dmm.measure_dc_voltage()
|
||||||
|
assert isinstance(measured_voltage, float)
|
||||||
|
# Should be reading DUT output voltage (close to nominal)
|
||||||
|
assert 3.0 < measured_voltage < 3.6 # LDO output
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_multiple_test_runs(simulation_server: ServerConfig) -> None:
|
||||||
|
"""Test running multiple tests sequentially.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
- Multiple tests can be run in sequence
|
||||||
|
- Each test gets its own run ID
|
||||||
|
- All results are stored correctly
|
||||||
|
- Repository handles multiple runs
|
||||||
|
"""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
config = InstrumentConfig(
|
||||||
|
backend="simulator",
|
||||||
|
simulator_host=simulation_server.host,
|
||||||
|
chamber_port=simulation_server.chamber_port,
|
||||||
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments = InstrumentFactory.create(config)
|
||||||
|
|
||||||
|
# Connect instruments
|
||||||
|
instruments.chamber.transport.connect()
|
||||||
|
instruments.psu.transport.connect()
|
||||||
|
instruments.dmm.transport.connect()
|
||||||
|
|
||||||
|
db_path = Path(tmpdir) / "test.db"
|
||||||
|
repository = SQLiteRepository(str(db_path))
|
||||||
|
runner = TestRunner(repository)
|
||||||
|
|
||||||
|
# Run same test twice with different configs
|
||||||
|
test = TempCoTest()
|
||||||
|
|
||||||
|
config1 = {
|
||||||
|
"temperatures": [0.0, 25.0], # Need at least 2 points for TempCo
|
||||||
|
"input_voltage": 5.0,
|
||||||
|
"load_current": 0.1,
|
||||||
|
"settle_time": 0.5,
|
||||||
|
"num_samples": 3,
|
||||||
|
"tempco_limit": 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
config2 = {
|
||||||
|
"temperatures": [25.0, 50.0], # Need at least 2 points for TempCo
|
||||||
|
"input_voltage": 3.3,
|
||||||
|
"load_current": 0.05,
|
||||||
|
"settle_time": 0.5,
|
||||||
|
"num_samples": 3,
|
||||||
|
"tempco_limit": 100.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
run_id1 = runner.run_test(test, instruments, config1, operator="test_user_1")
|
||||||
|
run_id2 = runner.run_test(test, instruments, config2, operator="test_user_2")
|
||||||
|
|
||||||
|
# Both runs should complete
|
||||||
|
assert run_id1 != run_id2
|
||||||
|
|
||||||
|
run1 = repository.get_run(run_id1)
|
||||||
|
run2 = repository.get_run(run_id2)
|
||||||
|
|
||||||
|
assert run1.operator == "test_user_1"
|
||||||
|
assert run2.operator == "test_user_2"
|
||||||
|
|
||||||
|
# Both should have results
|
||||||
|
results1 = repository.get_results(run_id1)
|
||||||
|
results2 = repository.get_results(run_id2)
|
||||||
|
|
||||||
|
assert len(results1) > 0
|
||||||
|
assert len(results2) > 0
|
||||||
|
|
||||||
|
# Verify get_all_runs works
|
||||||
|
all_runs = repository.get_all_runs()
|
||||||
|
assert len(all_runs) >= 2
|
||||||
|
assert any(r.id == str(run_id1) for r in all_runs)
|
||||||
|
assert any(r.id == str(run_id2) for r in all_runs)
|
||||||
|
|
||||||
|
# Cleanup: close repository before tempdir cleanup (Windows file locking)
|
||||||
|
repository.close()
|
||||||
@@ -73,6 +73,8 @@ class TestInstrumentServer:
|
|||||||
# Set temperature setpoint
|
# Set temperature setpoint
|
||||||
writer.write(b"TEMP:SETPOINT 85.0\n")
|
writer.write(b"TEMP:SETPOINT 85.0\n")
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
|
# Small delay to ensure server processes command before next one
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
# Query setpoint
|
# Query setpoint
|
||||||
writer.write(b"TEMP:SETPOINT?\n")
|
writer.write(b"TEMP:SETPOINT?\n")
|
||||||
@@ -212,6 +214,7 @@ class TestSimulationServer:
|
|||||||
psu_r, psu_w = await asyncio.open_connection("127.0.0.1", 16201)
|
psu_r, psu_w = await asyncio.open_connection("127.0.0.1", 16201)
|
||||||
psu_w.write(b"VOLT 5.0\n")
|
psu_w.write(b"VOLT 5.0\n")
|
||||||
await psu_w.drain()
|
await psu_w.drain()
|
||||||
|
await asyncio.sleep(0.01) # Allow server to process
|
||||||
psu_w.write(b"OUTP ON\n")
|
psu_w.write(b"OUTP ON\n")
|
||||||
await psu_w.drain()
|
await psu_w.drain()
|
||||||
|
|
||||||
|
|||||||
@@ -7,96 +7,84 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from py_dvt_ate.data.models import TestStatus
|
from py_dvt_ate.data.models import TestStatus
|
||||||
from py_dvt_ate.data.repository import SQLiteRepository
|
from py_dvt_ate.data.repository import SQLiteRepository
|
||||||
from py_dvt_ate.framework.context import TestContext
|
from py_dvt_ate.framework.context import TestContext
|
||||||
from py_dvt_ate.framework.logger import TestLogger
|
from py_dvt_ate.framework.logger import TestLogger
|
||||||
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
|
||||||
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
from py_dvt_ate.simulation.server import ServerConfig
|
||||||
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="function")
|
|
||||||
class TestTempCoIntegration:
|
class TestTempCoIntegration:
|
||||||
"""Integration tests for TempCo test with simulator."""
|
"""Integration tests for TempCo test with simulator."""
|
||||||
|
|
||||||
async def test_tempco_runs_successfully(self, tmp_path: Path) -> None:
|
def test_tempco_runs_successfully(
|
||||||
|
self, tmp_path: Path, simulation_server: ServerConfig
|
||||||
|
) -> None:
|
||||||
"""Test TempCo test runs end-to-end with simulator."""
|
"""Test TempCo test runs end-to-end with simulator."""
|
||||||
# Start simulation server
|
# Create instrument set connected to simulator
|
||||||
server_config = ServerConfig(
|
instrument_config = InstrumentConfig(
|
||||||
host="127.0.0.1",
|
backend="simulator",
|
||||||
chamber_port=17000,
|
simulator_host=simulation_server.host,
|
||||||
psu_port=17001,
|
chamber_port=simulation_server.chamber_port,
|
||||||
dmm_port=17002,
|
psu_port=simulation_server.psu_port,
|
||||||
physics_rate_hz=100.0,
|
dmm_port=simulation_server.dmm_port,
|
||||||
)
|
)
|
||||||
server = SimulationServer(server_config)
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
await server.start()
|
|
||||||
|
# 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:
|
try:
|
||||||
# Create instrument set connected to simulator
|
|
||||||
instrument_config = InstrumentConfig(
|
|
||||||
backend="simulator",
|
|
||||||
simulator_host="127.0.0.1",
|
|
||||||
chamber_port=17000,
|
|
||||||
psu_port=17001,
|
|
||||||
dmm_port=17002,
|
|
||||||
)
|
|
||||||
instruments = InstrumentFactory.create(instrument_config)
|
|
||||||
|
|
||||||
# Connect to instruments
|
|
||||||
instruments.chamber.connect()
|
|
||||||
instruments.psu.connect()
|
|
||||||
instruments.dmm.connect()
|
|
||||||
|
|
||||||
# Configure instruments
|
# Configure instruments
|
||||||
instruments.chamber.set_ramp_rate(10.0) # Fast ramp for testing
|
instruments.chamber.set_ramp_rate(10.0) # Fast ramp for testing
|
||||||
instruments.psu.enable_output(1, False) # Ensure off initially
|
instruments.psu.enable_output(1, False) # Ensure off initially
|
||||||
|
|
||||||
# Create test repository
|
# Run test
|
||||||
db_path = tmp_path / "test.db"
|
|
||||||
repository = SQLiteRepository(db_path)
|
|
||||||
|
|
||||||
# Create test run
|
|
||||||
run_id = repository.create_run(
|
|
||||||
test_name="tempco",
|
|
||||||
config={
|
|
||||||
"temperatures": [0.0, 25.0, 50.0], # Reduced for faster test
|
|
||||||
"input_voltage": 5.0,
|
|
||||||
"load_current": 0.1,
|
|
||||||
"settle_time": 0.5, # Reduced for faster test
|
|
||||||
"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": [0.0, 25.0, 50.0],
|
|
||||||
"input_voltage": 5.0,
|
|
||||||
"load_current": 0.1,
|
|
||||||
"settle_time": 0.5,
|
|
||||||
"num_samples": 3,
|
|
||||||
"tempco_limit": 100.0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create and execute test
|
|
||||||
test = TempCoTest()
|
|
||||||
assert test.name == "tempco"
|
|
||||||
assert test.description == "Output voltage temperature coefficient"
|
|
||||||
|
|
||||||
# Run test (this is synchronous, but simulation runs async in background)
|
|
||||||
status = test.execute(context)
|
status = test.execute(context)
|
||||||
|
|
||||||
# Verify test completed
|
# Verify test completed
|
||||||
@@ -115,7 +103,7 @@ class TestTempCoIntegration:
|
|||||||
# Find TempCo result
|
# Find TempCo result
|
||||||
tempco_result = next(r for r in results if r.parameter == "temp_co")
|
tempco_result = next(r for r in results if r.parameter == "temp_co")
|
||||||
assert tempco_result is not None
|
assert tempco_result is not None
|
||||||
assert tempco_result.unit == "ppm/°C"
|
assert tempco_result.unit == "ppm/C"
|
||||||
assert tempco_result.lower_limit == -100.0
|
assert tempco_result.lower_limit == -100.0
|
||||||
assert tempco_result.upper_limit == 100.0
|
assert tempco_result.upper_limit == 100.0
|
||||||
|
|
||||||
@@ -134,60 +122,57 @@ class TestTempCoIntegration:
|
|||||||
assert len(temps_recorded) >= 3
|
assert len(temps_recorded) >= 3
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
# Disconnect from instruments
|
||||||
|
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
async def test_tempco_with_minimal_config(self, tmp_path: Path) -> None:
|
def test_tempco_with_minimal_config(
|
||||||
|
self, tmp_path: Path, simulation_server: ServerConfig
|
||||||
|
) -> None:
|
||||||
"""Test TempCo uses default configuration when not specified."""
|
"""Test TempCo uses default configuration when not specified."""
|
||||||
# Start simulation server
|
# Create instrument set
|
||||||
server_config = ServerConfig(
|
instrument_config = InstrumentConfig(
|
||||||
host="127.0.0.1",
|
backend="simulator",
|
||||||
chamber_port=17100,
|
simulator_host=simulation_server.host,
|
||||||
psu_port=17101,
|
chamber_port=simulation_server.chamber_port,
|
||||||
dmm_port=17102,
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
)
|
)
|
||||||
server = SimulationServer(server_config)
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
await server.start()
|
|
||||||
|
# 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:
|
try:
|
||||||
# Create instrument set
|
# Run test
|
||||||
instrument_config = InstrumentConfig(
|
|
||||||
backend="simulator",
|
|
||||||
simulator_host="127.0.0.1",
|
|
||||||
chamber_port=17100,
|
|
||||||
psu_port=17101,
|
|
||||||
dmm_port=17102,
|
|
||||||
)
|
|
||||||
instruments = InstrumentFactory.create(instrument_config)
|
|
||||||
|
|
||||||
# Connect to instruments
|
|
||||||
instruments.chamber.connect()
|
|
||||||
instruments.psu.connect()
|
|
||||||
instruments.dmm.connect()
|
|
||||||
|
|
||||||
# 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": [25.0, 50.0],
|
|
||||||
"settle_time": 0.2,
|
|
||||||
"num_samples": 2,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute test
|
|
||||||
test = TempCoTest()
|
|
||||||
status = test.execute(context)
|
status = test.execute(context)
|
||||||
|
|
||||||
# Should complete without error
|
# Should complete without error
|
||||||
@@ -201,56 +186,51 @@ class TestTempCoIntegration:
|
|||||||
assert len(results) >= 1
|
assert len(results) >= 1
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
# Disconnect from instruments
|
||||||
|
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||||
|
|
||||||
async def test_tempco_handles_errors_gracefully(self, tmp_path: Path) -> None:
|
def test_tempco_handles_errors_gracefully(
|
||||||
|
self, tmp_path: Path, simulation_server: ServerConfig
|
||||||
|
) -> None:
|
||||||
"""Test TempCo returns ERROR status when instruments fail."""
|
"""Test TempCo returns ERROR status when instruments fail."""
|
||||||
# Start simulation server
|
# Create instrument set
|
||||||
server_config = ServerConfig(
|
instrument_config = InstrumentConfig(
|
||||||
host="127.0.0.1",
|
backend="simulator",
|
||||||
chamber_port=17200,
|
simulator_host=simulation_server.host,
|
||||||
psu_port=17201,
|
chamber_port=simulation_server.chamber_port,
|
||||||
dmm_port=17202,
|
psu_port=simulation_server.psu_port,
|
||||||
|
dmm_port=simulation_server.dmm_port,
|
||||||
)
|
)
|
||||||
server = SimulationServer(server_config)
|
instruments = InstrumentFactory.create(instrument_config)
|
||||||
await server.start()
|
|
||||||
|
# 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:
|
try:
|
||||||
# Create instrument set
|
|
||||||
instrument_config = InstrumentConfig(
|
|
||||||
backend="simulator",
|
|
||||||
simulator_host="127.0.0.1",
|
|
||||||
chamber_port=17200,
|
|
||||||
psu_port=17201,
|
|
||||||
dmm_port=17202,
|
|
||||||
)
|
|
||||||
instruments = InstrumentFactory.create(instrument_config)
|
|
||||||
|
|
||||||
# Connect to instruments
|
|
||||||
instruments.chamber.connect()
|
|
||||||
instruments.psu.connect()
|
|
||||||
instruments.dmm.connect()
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
# Should handle gracefully (may return FAILED or ERROR)
|
# Should handle gracefully (may return FAILED or ERROR)
|
||||||
# The test should not raise an unhandled exception
|
# The test should not raise an unhandled exception
|
||||||
try:
|
try:
|
||||||
@@ -264,4 +244,7 @@ class TestTempCoIntegration:
|
|||||||
logger.flush()
|
logger.flush()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await server.stop()
|
# Disconnect from instruments
|
||||||
|
instruments.chamber.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.psu.disconnect() # type: ignore[attr-defined]
|
||||||
|
instruments.dmm.disconnect() # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import pytest
|
|||||||
|
|
||||||
from py_dvt_ate.instruments import (
|
from py_dvt_ate.instruments import (
|
||||||
IMultimeter,
|
IMultimeter,
|
||||||
IPowerSupply,
|
|
||||||
IThermalChamber,
|
|
||||||
InstrumentConfig,
|
InstrumentConfig,
|
||||||
InstrumentFactory,
|
InstrumentFactory,
|
||||||
InstrumentSet,
|
InstrumentSet,
|
||||||
|
IPowerSupply,
|
||||||
|
IThermalChamber,
|
||||||
)
|
)
|
||||||
from py_dvt_ate.instruments.drivers import (
|
from py_dvt_ate.instruments.drivers import (
|
||||||
MultimeterDriver,
|
MultimeterDriver,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from py_dvt_ate.data.repository import SQLiteRepository
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_db():
|
def temp_db():
|
||||||
"""Create a temporary database for testing."""
|
"""Create a temporary database for testing."""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
# Use ignore_cleanup_errors=True for Windows file locking issues
|
||||||
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test.db"
|
db_path = Path(tmpdir) / "test.db"
|
||||||
yield db_path
|
yield db_path
|
||||||
|
|
||||||
@@ -22,7 +23,14 @@ def temp_db():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repository(temp_db):
|
def repository(temp_db):
|
||||||
"""Create a repository instance for testing."""
|
"""Create a repository instance for testing."""
|
||||||
return SQLiteRepository(temp_db)
|
import gc
|
||||||
|
repo = SQLiteRepository(temp_db)
|
||||||
|
yield repo
|
||||||
|
# Ensure all connections and file handles are closed before cleanup
|
||||||
|
# This is critical on Windows to prevent PermissionError
|
||||||
|
repo.close()
|
||||||
|
del repo
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
def test_create_run(repository):
|
def test_create_run(repository):
|
||||||
@@ -235,6 +243,7 @@ def test_multiple_results(repository):
|
|||||||
|
|
||||||
def test_custom_measurements_dir(temp_db):
|
def test_custom_measurements_dir(temp_db):
|
||||||
"""Test using a custom measurements directory."""
|
"""Test using a custom measurements directory."""
|
||||||
|
import gc
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
measurements_dir = Path(tmpdir) / "custom_measurements"
|
measurements_dir = Path(tmpdir) / "custom_measurements"
|
||||||
repo = SQLiteRepository(temp_db, measurements_dir=measurements_dir)
|
repo = SQLiteRepository(temp_db, measurements_dir=measurements_dir)
|
||||||
@@ -254,6 +263,10 @@ def test_custom_measurements_dir(temp_db):
|
|||||||
expected_path = measurements_dir / f"run_{run_id}" / "measurements.parquet"
|
expected_path = measurements_dir / f"run_{run_id}" / "measurements.parquet"
|
||||||
assert expected_path.exists()
|
assert expected_path.exists()
|
||||||
|
|
||||||
|
# Clean up repository before temp directory cleanup
|
||||||
|
del repo
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
def test_parquet_schema(repository):
|
def test_parquet_schema(repository):
|
||||||
"""Test that Parquet file has correct schema."""
|
"""Test that Parquet file has correct schema."""
|
||||||
|
|||||||
Reference in New Issue
Block a user