Compare commits
54 Commits
v0.1.0-alp
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
78
CHANGELOG.md
78
CHANGELOG.md
@@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [0.1.0-alpha.2] - 2025-12-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -57,9 +129,9 @@ 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 | TBD | MVP Complete |
|
||||||
| 0.1.0-beta.2 | TBD | First DVT test runs |
|
| 0.1.0-beta.2 | 2025-12-03 | First DVT test runs |
|
||||||
| 0.1.0-beta.1 | TBD | HAL complete |
|
| 0.1.0-beta.1 | 2025-12-02 | HAL complete |
|
||||||
| 0.1.0-alpha.3 | TBD | Network ready |
|
| 0.1.0-alpha.3 | 2025-12-02 | Network ready |
|
||||||
| 0.1.0-alpha.2 | 2025-12-02 | Visual demo |
|
| 0.1.0-alpha.2 | 2025-12-02 | Visual demo |
|
||||||
| 0.1.0-alpha.1 | 2025-12-02 | Physics engine |
|
| 0.1.0-alpha.1 | 2025-12-02 | Physics engine |
|
||||||
| 0.0.1 | 2025-12-01 | Project scaffolding |
|
| 0.0.1 | 2025-12-01 | Project scaffolding |
|
||||||
|
|||||||
149
config/default.yaml
Normal file
149
config/default.yaml
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 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
|
||||||
@@ -450,109 +450,123 @@ High-level call → Driver → SCPI command → Transport → Instrument
|
|||||||
```python
|
```python
|
||||||
# py_dvt_ate/instruments/interfaces.py
|
# py_dvt_ate/instruments/interfaces.py
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IThermalChamber(Protocol):
|
class IThermalChamber(ABC):
|
||||||
"""Hardware abstraction for thermal chambers."""
|
"""Hardware abstraction for thermal chambers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def set_temperature(self, setpoint: float) -> None:
|
def set_temperature(self, setpoint: float) -> None:
|
||||||
"""Set target temperature in degrees Celsius."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def get_temperature(self) -> float:
|
def get_temperature(self) -> float:
|
||||||
"""Get current actual temperature in degrees Celsius."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def get_setpoint(self) -> float:
|
def get_setpoint(self) -> float:
|
||||||
"""Get current temperature setpoint."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def is_stable(self) -> bool:
|
def is_stable(self) -> bool:
|
||||||
"""Check if temperature has stabilised at setpoint."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def wait_until_stable(
|
def wait_until_stable(
|
||||||
self,
|
self,
|
||||||
timeout: float = 300.0,
|
timeout: float = 300.0,
|
||||||
poll_interval: float = 1.0
|
poll_interval: float = 1.0
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""[docstring]"""
|
||||||
Block until temperature stabilises or timeout.
|
pass
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if stable, False if timeout
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def set_ramp_rate(self, rate: float) -> None:
|
def set_ramp_rate(self, rate: float) -> None:
|
||||||
"""Set temperature ramp rate in degrees C per minute."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IPowerSupply(Protocol):
|
class IPowerSupply(ABC):
|
||||||
"""Hardware abstraction for programmable power supplies."""
|
"""Hardware abstraction for programmable power supplies."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def set_voltage(self, channel: int, voltage: float) -> None:
|
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:
|
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:
|
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:
|
def get_current_limit(self, channel: int) -> float:
|
||||||
"""Get current limit for specified channel."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def measure_voltage(self, channel: int) -> float:
|
def measure_voltage(self, channel: int) -> float:
|
||||||
"""Measure actual output voltage."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def measure_current(self, channel: int) -> float:
|
def measure_current(self, channel: int) -> float:
|
||||||
"""Measure actual output current."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def enable_output(self, channel: int, enable: bool) -> None:
|
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:
|
def is_output_enabled(self, channel: int) -> bool:
|
||||||
"""Check if channel output is enabled."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IMultimeter(Protocol):
|
class IMultimeter(ABC):
|
||||||
"""Hardware abstraction for digital multimeters."""
|
"""Hardware abstraction for digital multimeters."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def measure_dc_voltage(self, range: str = "AUTO") -> float:
|
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:
|
def measure_dc_current(self, range: str = "AUTO") -> float:
|
||||||
"""Measure DC current."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def measure_resistance(self, range: str = "AUTO") -> float:
|
def measure_resistance(self, range: str = "AUTO") -> float:
|
||||||
"""Measure resistance."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def set_integration_time(self, nplc: float) -> None:
|
def set_integration_time(self, nplc: float) -> None:
|
||||||
"""Set integration time in power line cycles (0.1 to 100)."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class ITestLogger(Protocol):
|
class ITestLogger(ABC):
|
||||||
"""Abstraction for test data logging."""
|
"""Abstraction for test data logging."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def log_measurement(
|
def log_measurement(
|
||||||
self,
|
self,
|
||||||
parameter: str,
|
parameter: str,
|
||||||
@@ -560,9 +574,10 @@ class ITestLogger(Protocol):
|
|||||||
unit: str,
|
unit: str,
|
||||||
conditions: dict[str, float] | None = None
|
conditions: dict[str, float] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Log a single measurement."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def log_result(
|
def log_result(
|
||||||
self,
|
self,
|
||||||
parameter: str,
|
parameter: str,
|
||||||
@@ -571,12 +586,13 @@ class ITestLogger(Protocol):
|
|||||||
lower_limit: float | None = None,
|
lower_limit: float | None = None,
|
||||||
upper_limit: float | None = None
|
upper_limit: float | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Log a test result with optional limits."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def log_event(self, message: str, level: str = "INFO") -> None:
|
def log_event(self, message: str, level: str = "INFO") -> None:
|
||||||
"""Log a test event or message."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Transport Interface
|
### 4.2 Transport Interface
|
||||||
@@ -584,36 +600,42 @@ class ITestLogger(Protocol):
|
|||||||
```python
|
```python
|
||||||
# py_dvt_ate/instruments/transport/base.py
|
# 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."""
|
"""Abstract transport interface for instrument communication."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
"""Establish connection to instrument."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
"""Close connection to instrument."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def write(self, command: str) -> None:
|
def write(self, command: str) -> None:
|
||||||
"""Send command to instrument."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def read(self, timeout: float | None = None) -> str:
|
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:
|
def query(self, command: str, timeout: float | None = None) -> str:
|
||||||
"""Send command and read response."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@abstractmethod
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Check if connection is active."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 Test Interface
|
### 4.3 Test Interface
|
||||||
@@ -624,7 +646,7 @@ class Transport(Protocol):
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Protocol
|
from abc import ABC, abstractmethod
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
@@ -675,22 +697,25 @@ class TestContext:
|
|||||||
config: dict
|
config: dict
|
||||||
|
|
||||||
|
|
||||||
class ITest(Protocol):
|
class ITest(ABC):
|
||||||
"""Interface for test implementations."""
|
"""Interface for test implementations."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@abstractmethod
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
"""Test name identifier."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@abstractmethod
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
"""Human-readable test description."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def execute(self, context: TestContext) -> TestStatus:
|
def execute(self, context: TestContext) -> TestStatus:
|
||||||
"""Execute the test, return status."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 Factory Interface
|
### 4.4 Factory Interface
|
||||||
@@ -1173,30 +1198,34 @@ Schema:
|
|||||||
```python
|
```python
|
||||||
# py_dvt_ate/data/repository.py (interface)
|
# py_dvt_ate/data/repository.py (interface)
|
||||||
|
|
||||||
from typing import Protocol
|
from abc import ABC, abstractmethod
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
class ITestRepository(Protocol):
|
class ITestRepository(ABC):
|
||||||
"""Repository interface for test data."""
|
"""Repository interface for test data."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def create_run(
|
def create_run(
|
||||||
self,
|
self,
|
||||||
test_name: str,
|
test_name: str,
|
||||||
config: dict,
|
config: dict,
|
||||||
operator: str | None = None
|
operator: str | None = None
|
||||||
) -> UUID:
|
) -> UUID:
|
||||||
"""Create a new test run, return its ID."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def update_run_status(self, run_id: UUID, status: str) -> None:
|
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:
|
def complete_run(self, run_id: UUID, status: str) -> None:
|
||||||
"""Mark test run as complete with final status."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def save_result(
|
def save_result(
|
||||||
self,
|
self,
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
@@ -1206,28 +1235,32 @@ class ITestRepository(Protocol):
|
|||||||
lower_limit: float | None = None,
|
lower_limit: float | None = None,
|
||||||
upper_limit: float | None = None
|
upper_limit: float | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save a test result."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def save_measurements(
|
def save_measurements(
|
||||||
self,
|
self,
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
measurements: list["Measurement"]
|
measurements: list["Measurement"]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save batch of measurements to Parquet."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def get_run(self, run_id: UUID) -> "TestRun":
|
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"]:
|
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):
|
def get_measurements_dataframe(self, run_id: UUID):
|
||||||
"""Get measurements as pandas DataFrame."""
|
"""[docstring]"""
|
||||||
...
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dev = [
|
|||||||
"pytest-asyncio>=0.21",
|
"pytest-asyncio>=0.21",
|
||||||
"ruff>=0.1",
|
"ruff>=0.1",
|
||||||
"mypy>=1.0",
|
"mypy>=1.0",
|
||||||
|
"types-PyYAML>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -86,5 +87,8 @@ ignore_missing_imports = true
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
|
||||||
addopts = "-v --tb=short"
|
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."""
|
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
|
||||||
|
|
||||||
__version__ = "0.1.0-alpha.2"
|
__version__ = "0.1.0-beta.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Command-line interface for py_dvt_ate."""
|
"""Command-line interface for py_dvt_ate."""
|
||||||
|
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ def version_callback(value: bool) -> None:
|
|||||||
@app.callback()
|
@app.callback()
|
||||||
def main(
|
def main(
|
||||||
version: Annotated[
|
version: Annotated[
|
||||||
Optional[bool],
|
bool | None,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
"--version",
|
"--version",
|
||||||
"-v",
|
"-v",
|
||||||
@@ -36,5 +36,52 @@ def main(
|
|||||||
"""py-dvt-ate: Coupled Physics DVT Simulation Platform."""
|
"""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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
200
src/py_dvt_ate/app/config.py
Normal file
200
src/py_dvt_ate/app/config.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""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 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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -4,6 +4,7 @@ This module provides an interactive dashboard for visualising the physics
|
|||||||
engine directly, demonstrating thermal-electrical coupling in real-time.
|
engine directly, demonstrating thermal-electrical coupling in real-time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ import streamlit as st
|
|||||||
|
|
||||||
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||||
|
|
||||||
|
|
||||||
# History buffer size for charts
|
# History buffer size for charts
|
||||||
HISTORY_SIZE = 500
|
HISTORY_SIZE = 500
|
||||||
|
|
||||||
@@ -44,12 +44,29 @@ def init_session_state() -> None:
|
|||||||
st.session_state.history = SimulationHistory()
|
st.session_state.history = SimulationHistory()
|
||||||
if "running" not in st.session_state:
|
if "running" not in st.session_state:
|
||||||
st.session_state.running = False
|
st.session_state.running = False
|
||||||
|
if "last_update" not in st.session_state:
|
||||||
|
st.session_state.last_update = time.time()
|
||||||
|
# Note: time_multiplier, temp_setpoint, input_voltage, output_enabled,
|
||||||
|
# load_current are managed by their respective widgets via keys
|
||||||
|
|
||||||
|
|
||||||
def step_simulation(steps: int = 10) -> None:
|
def step_simulation() -> None:
|
||||||
"""Advance the simulation by the given number of steps."""
|
"""Advance the simulation based on elapsed real time and multiplier."""
|
||||||
engine: PhysicsEngine = st.session_state.engine
|
engine: PhysicsEngine = st.session_state.engine
|
||||||
history: SimulationHistory = st.session_state.history
|
history: SimulationHistory = st.session_state.history
|
||||||
|
multiplier: float = st.session_state.get("time_multiplier", 10)
|
||||||
|
|
||||||
|
# Calculate how much simulation time to advance
|
||||||
|
current_time = time.time()
|
||||||
|
elapsed_real = current_time - st.session_state.last_update
|
||||||
|
st.session_state.last_update = current_time
|
||||||
|
|
||||||
|
# Simulation time to advance (capped to prevent huge jumps)
|
||||||
|
sim_time_to_advance = min(elapsed_real * multiplier, 2.0)
|
||||||
|
|
||||||
|
# Calculate number of steps needed
|
||||||
|
steps = int(sim_time_to_advance / engine.dt)
|
||||||
|
steps = max(1, min(steps, 1000)) # Clamp between 1 and 1000 steps
|
||||||
|
|
||||||
for _ in range(steps):
|
for _ in range(steps):
|
||||||
engine.step()
|
engine.step()
|
||||||
@@ -66,105 +83,127 @@ def step_simulation(steps: int = 10) -> None:
|
|||||||
history.power_dissipation.append(electrical.power_dissipation)
|
history.power_dissipation.append(electrical.power_dissipation)
|
||||||
|
|
||||||
|
|
||||||
def display_thermal_chart() -> None:
|
def sync_engine_from_session_state() -> None:
|
||||||
"""Display temperature chart."""
|
"""Sync engine parameters from session state (called by fragment)."""
|
||||||
history: SimulationHistory = st.session_state.history
|
engine: PhysicsEngine = st.session_state.engine
|
||||||
|
engine.set_chamber_setpoint(st.session_state.get("temp_setpoint", 25.0))
|
||||||
|
engine.set_input_voltage(st.session_state.get("input_voltage", 5.0))
|
||||||
|
engine.set_output_enabled(st.session_state.get("output_enabled", False))
|
||||||
|
engine.set_load_current(st.session_state.get("load_current", 100.0) / 1000.0)
|
||||||
|
|
||||||
if len(history.time) < 2:
|
|
||||||
st.info("Start the simulation to see temperature data")
|
|
||||||
return
|
|
||||||
|
|
||||||
chart_data = {
|
def display_controls() -> None:
|
||||||
"Time (s)": list(history.time),
|
"""Display simulation control panel in sidebar."""
|
||||||
"Chamber": list(history.chamber_temp),
|
st.sidebar.header("Simulation Controls")
|
||||||
"Case": list(history.case_temp),
|
|
||||||
"Junction": list(history.junction_temp),
|
|
||||||
}
|
|
||||||
|
|
||||||
st.line_chart(
|
# Start/Stop button
|
||||||
chart_data,
|
if st.session_state.running:
|
||||||
x="Time (s)",
|
if st.sidebar.button(
|
||||||
y=["Chamber", "Case", "Junction"],
|
"Stop Simulation", type="primary", use_container_width=True
|
||||||
color=["#1f77b4", "#ff7f0e", "#d62728"],
|
):
|
||||||
|
st.session_state.running = False
|
||||||
|
else:
|
||||||
|
if st.sidebar.button(
|
||||||
|
"Start Simulation", type="primary", use_container_width=True
|
||||||
|
):
|
||||||
|
st.session_state.running = True
|
||||||
|
st.session_state.last_update = time.time()
|
||||||
|
|
||||||
|
# Reset button
|
||||||
|
if st.sidebar.button("Reset", use_container_width=True):
|
||||||
|
st.session_state.engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
st.session_state.history = SimulationHistory()
|
||||||
|
st.session_state.running = False
|
||||||
|
st.session_state.last_update = time.time()
|
||||||
|
|
||||||
|
st.sidebar.divider()
|
||||||
|
|
||||||
|
# Time multiplier
|
||||||
|
st.sidebar.subheader("Simulation Speed")
|
||||||
|
st.sidebar.select_slider(
|
||||||
|
"Time Multiplier",
|
||||||
|
options=[1, 2, 5, 10, 20, 50, 100],
|
||||||
|
value=10,
|
||||||
|
format_func=lambda x: f"{x}x",
|
||||||
|
key="time_multiplier",
|
||||||
|
)
|
||||||
|
st.sidebar.caption(
|
||||||
|
f"1 real second = {st.session_state.get('time_multiplier', 10)} simulation seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
st.sidebar.divider()
|
||||||
|
|
||||||
|
# Temperature setpoint
|
||||||
|
st.sidebar.subheader("Thermal Chamber")
|
||||||
|
st.sidebar.slider(
|
||||||
|
"Temperature Setpoint (C)",
|
||||||
|
min_value=-40.0,
|
||||||
|
max_value=125.0,
|
||||||
|
value=25.0,
|
||||||
|
step=5.0,
|
||||||
|
key="temp_setpoint",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.sidebar.divider()
|
||||||
|
|
||||||
|
# Power supply controls
|
||||||
|
st.sidebar.subheader("Power Supply")
|
||||||
|
st.sidebar.slider(
|
||||||
|
"Input Voltage (V)",
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=12.0,
|
||||||
|
value=5.0,
|
||||||
|
step=0.1,
|
||||||
|
key="input_voltage",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.sidebar.toggle(
|
||||||
|
"Output Enabled",
|
||||||
|
value=False,
|
||||||
|
key="output_enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.sidebar.divider()
|
||||||
|
|
||||||
|
# Load controls
|
||||||
|
st.sidebar.subheader("Electronic Load")
|
||||||
|
st.sidebar.slider(
|
||||||
|
"Load Current (mA)",
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=500.0,
|
||||||
|
value=100.0,
|
||||||
|
step=10.0,
|
||||||
|
key="load_current",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def display_self_heating_panel() -> None:
|
@st.fragment(run_every=0.1)
|
||||||
"""Display self-heating demonstration panel."""
|
def simulation_display() -> None:
|
||||||
|
"""Fragment that displays and updates simulation state."""
|
||||||
engine: PhysicsEngine = st.session_state.engine
|
engine: PhysicsEngine = st.session_state.engine
|
||||||
history: SimulationHistory = st.session_state.history
|
history: SimulationHistory = st.session_state.history
|
||||||
|
|
||||||
|
# Sync engine parameters from UI controls
|
||||||
|
sync_engine_from_session_state()
|
||||||
|
|
||||||
|
# Step simulation if running
|
||||||
|
if st.session_state.running:
|
||||||
|
step_simulation()
|
||||||
|
|
||||||
|
# Get current state
|
||||||
thermal = engine.get_thermal_state()
|
thermal = engine.get_thermal_state()
|
||||||
electrical = engine.get_electrical_state()
|
electrical = engine.get_electrical_state()
|
||||||
|
|
||||||
# Calculate temperature rises
|
# Current state metrics
|
||||||
delta_t_jc = thermal.junction_temperature - thermal.case_temperature
|
st.subheader("Current State")
|
||||||
delta_t_ca = thermal.case_temperature - thermal.chamber_temperature
|
|
||||||
|
|
||||||
col1, col2 = st.columns(2)
|
|
||||||
|
|
||||||
with col1:
|
|
||||||
st.markdown("#### Self-Heating Analysis")
|
|
||||||
|
|
||||||
# Display thermal resistance info
|
|
||||||
st.markdown(
|
|
||||||
f"""
|
|
||||||
| Parameter | Value |
|
|
||||||
|-----------|-------|
|
|
||||||
| Junction-Case Rise (ΔT_jc) | **{delta_t_jc:.2f} °C** |
|
|
||||||
| Case-Ambient Rise (ΔT_ca) | **{delta_t_ca:.2f} °C** |
|
|
||||||
| Power Dissipation | {electrical.power_dissipation * 1000:.1f} mW |
|
|
||||||
| θ_jc (junction-case) | 15 °C/W |
|
|
||||||
| θ_ca (case-ambient) | 5 °C/W |
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
st.markdown(
|
|
||||||
"""
|
|
||||||
**Thermal Coupling:** The junction temperature rises above the case
|
|
||||||
temperature due to power dissipation. This is governed by:
|
|
||||||
|
|
||||||
`T_junction = T_case + P_diss × θ_jc`
|
|
||||||
|
|
||||||
Try increasing the load current or input voltage to see
|
|
||||||
self-heating effects!
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
with col2:
|
|
||||||
st.markdown("#### Power Dissipation")
|
|
||||||
|
|
||||||
if len(history.time) < 2:
|
|
||||||
st.info("Start the simulation to see power data")
|
|
||||||
return
|
|
||||||
|
|
||||||
power_data = {
|
|
||||||
"Time (s)": list(history.time),
|
|
||||||
"Power (mW)": [p * 1000 for p in history.power_dissipation],
|
|
||||||
}
|
|
||||||
|
|
||||||
st.line_chart(
|
|
||||||
power_data,
|
|
||||||
x="Time (s)",
|
|
||||||
y="Power (mW)",
|
|
||||||
color="#2ca02c",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def display_current_state() -> None:
|
|
||||||
"""Display current simulation state metrics."""
|
|
||||||
engine: PhysicsEngine = st.session_state.engine
|
|
||||||
thermal = engine.get_thermal_state()
|
|
||||||
electrical = engine.get_electrical_state()
|
|
||||||
|
|
||||||
col1, col2, col3, col4 = st.columns(4)
|
col1, col2, col3, col4 = st.columns(4)
|
||||||
|
|
||||||
with col1:
|
with col1:
|
||||||
st.metric("Chamber Temp", f"{thermal.chamber_temperature:.2f} °C")
|
st.metric("Chamber Temp", f"{thermal.chamber_temperature:.2f} C")
|
||||||
with col2:
|
with col2:
|
||||||
st.metric("Case Temp", f"{thermal.case_temperature:.2f} °C")
|
st.metric("Case Temp", f"{thermal.case_temperature:.2f} C")
|
||||||
with col3:
|
with col3:
|
||||||
st.metric("Junction Temp", f"{thermal.junction_temperature:.2f} °C")
|
st.metric("Junction Temp", f"{thermal.junction_temperature:.2f} C")
|
||||||
with col4:
|
with col4:
|
||||||
st.metric("Output Voltage", f"{electrical.output_voltage:.4f} V")
|
st.metric("Output Voltage", f"{electrical.output_voltage:.4f} V")
|
||||||
|
|
||||||
@@ -177,82 +216,79 @@ def display_current_state() -> None:
|
|||||||
with col7:
|
with col7:
|
||||||
st.metric("Power Diss.", f"{electrical.power_dissipation * 1000:.2f} mW")
|
st.metric("Power Diss.", f"{electrical.power_dissipation * 1000:.2f} mW")
|
||||||
with col8:
|
with col8:
|
||||||
st.metric("Sim Time", f"{engine.simulation_time:.2f} s")
|
status = "Running" if st.session_state.running else "Stopped"
|
||||||
|
st.metric(
|
||||||
|
"Sim Time",
|
||||||
|
f"{engine.simulation_time:.1f} s",
|
||||||
|
delta=f"{status} @ {st.session_state.get('time_multiplier', 10):.0f}x",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Temperature chart
|
||||||
def display_controls() -> None:
|
st.subheader("Temperature History")
|
||||||
"""Display simulation control panel in sidebar."""
|
if len(history.time) < 2:
|
||||||
engine: PhysicsEngine = st.session_state.engine
|
st.info("Start the simulation to see temperature data")
|
||||||
|
|
||||||
st.sidebar.header("Simulation Controls")
|
|
||||||
|
|
||||||
# Start/Stop button
|
|
||||||
if st.session_state.running:
|
|
||||||
if st.sidebar.button("Stop Simulation", type="primary", use_container_width=True):
|
|
||||||
st.session_state.running = False
|
|
||||||
st.rerun()
|
|
||||||
else:
|
else:
|
||||||
if st.sidebar.button(
|
chart_data = {
|
||||||
"Start Simulation", type="primary", use_container_width=True
|
"Time (s)": list(history.time),
|
||||||
):
|
"Chamber": list(history.chamber_temp),
|
||||||
st.session_state.running = True
|
"Case": list(history.case_temp),
|
||||||
st.rerun()
|
"Junction": list(history.junction_temp),
|
||||||
|
}
|
||||||
|
st.line_chart(
|
||||||
|
chart_data,
|
||||||
|
x="Time (s)",
|
||||||
|
y=["Chamber", "Case", "Junction"],
|
||||||
|
color=["#1f77b4", "#ff7f0e", "#d62728"],
|
||||||
|
)
|
||||||
|
|
||||||
# Reset button
|
# Self-heating demonstration
|
||||||
if st.sidebar.button("Reset", use_container_width=True):
|
st.subheader("Self-Heating Demonstration")
|
||||||
st.session_state.engine = PhysicsEngine(update_rate_hz=100.0)
|
|
||||||
st.session_state.history = SimulationHistory()
|
|
||||||
st.session_state.running = False
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
st.sidebar.divider()
|
delta_t_jc = thermal.junction_temperature - thermal.case_temperature
|
||||||
|
delta_t_ca = thermal.case_temperature - thermal.chamber_temperature
|
||||||
|
|
||||||
# Temperature setpoint
|
col1, col2 = st.columns(2)
|
||||||
st.sidebar.subheader("Thermal Chamber")
|
|
||||||
temp_setpoint = st.sidebar.slider(
|
|
||||||
"Temperature Setpoint (°C)",
|
|
||||||
min_value=-40.0,
|
|
||||||
max_value=125.0,
|
|
||||||
value=25.0,
|
|
||||||
step=5.0,
|
|
||||||
key="temp_setpoint",
|
|
||||||
)
|
|
||||||
engine.set_chamber_setpoint(temp_setpoint)
|
|
||||||
|
|
||||||
st.sidebar.divider()
|
with col1:
|
||||||
|
st.markdown("#### Self-Heating Analysis")
|
||||||
|
st.markdown(
|
||||||
|
f"""
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Junction-Case Rise (dT_jc) | **{delta_t_jc:.2f} C** |
|
||||||
|
| Case-Ambient Rise (dT_ca) | **{delta_t_ca:.2f} C** |
|
||||||
|
| Power Dissipation | {electrical.power_dissipation * 1000:.1f} mW |
|
||||||
|
| theta_jc (junction-case) | 15 C/W |
|
||||||
|
| theta_ca (case-ambient) | 5 C/W |
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
st.markdown(
|
||||||
|
"""
|
||||||
|
**Thermal Coupling:** The junction temperature rises above the case
|
||||||
|
temperature due to power dissipation. This is governed by:
|
||||||
|
|
||||||
# Power supply controls
|
`T_junction = T_case + P_diss x theta_jc`
|
||||||
st.sidebar.subheader("Power Supply")
|
|
||||||
input_voltage = st.sidebar.slider(
|
|
||||||
"Input Voltage (V)",
|
|
||||||
min_value=0.0,
|
|
||||||
max_value=12.0,
|
|
||||||
value=5.0,
|
|
||||||
step=0.1,
|
|
||||||
key="input_voltage",
|
|
||||||
)
|
|
||||||
engine.set_input_voltage(input_voltage)
|
|
||||||
|
|
||||||
output_enabled = st.sidebar.toggle(
|
Try increasing the load current or input voltage to see
|
||||||
"Output Enabled",
|
self-heating effects!
|
||||||
value=engine.is_output_enabled,
|
"""
|
||||||
key="output_enabled",
|
)
|
||||||
)
|
|
||||||
engine.set_output_enabled(output_enabled)
|
|
||||||
|
|
||||||
st.sidebar.divider()
|
with col2:
|
||||||
|
st.markdown("#### Power Dissipation")
|
||||||
# Load controls
|
if len(history.time) < 2:
|
||||||
st.sidebar.subheader("Electronic Load")
|
st.info("Start the simulation to see power data")
|
||||||
load_current_ma = st.sidebar.slider(
|
else:
|
||||||
"Load Current (mA)",
|
power_data = {
|
||||||
min_value=0.0,
|
"Time (s)": list(history.time),
|
||||||
max_value=500.0,
|
"Power (mW)": [p * 1000 for p in history.power_dissipation],
|
||||||
value=100.0,
|
}
|
||||||
step=10.0,
|
st.line_chart(
|
||||||
key="load_current",
|
power_data,
|
||||||
)
|
x="Time (s)",
|
||||||
engine.set_load_current(load_current_ma / 1000.0)
|
y="Power (mW)",
|
||||||
|
color="#2ca02c",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -273,25 +309,11 @@ def main() -> None:
|
|||||||
|
|
||||||
init_session_state()
|
init_session_state()
|
||||||
|
|
||||||
# Sidebar controls
|
# Sidebar controls (static - doesn't need fragment)
|
||||||
display_controls()
|
display_controls()
|
||||||
|
|
||||||
# Current state display
|
# Dynamic simulation display (uses fragment for smooth updates)
|
||||||
st.subheader("Current State")
|
simulation_display()
|
||||||
display_current_state()
|
|
||||||
|
|
||||||
# Temperature chart
|
|
||||||
st.subheader("Temperature History")
|
|
||||||
display_thermal_chart()
|
|
||||||
|
|
||||||
# Self-heating demonstration
|
|
||||||
st.subheader("Self-Heating Demonstration")
|
|
||||||
display_self_heating_panel()
|
|
||||||
|
|
||||||
# Auto-refresh when running
|
|
||||||
if st.session_state.running:
|
|
||||||
step_simulation(steps=10)
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
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
|
||||||
359
src/py_dvt_ate/data/repository.py
Normal file
359
src/py_dvt_ate/data/repository.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
"""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."""
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -3,3 +3,20 @@
|
|||||||
Provides test sequencing, measurement logging, limit checking,
|
Provides test sequencing, measurement logging, limit checking,
|
||||||
and runtime context management for DVT characterisation tests.
|
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
|
- Instrument drivers
|
||||||
- Factory for creating configured instrument sets
|
- 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
|
Each driver translates high-level operations into SCPI commands
|
||||||
and handles responses from instruments.
|
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.
|
"""Transport layer for instrument communication.
|
||||||
|
|
||||||
Provides connection abstractions for different backends:
|
Provides connection abstractions for different backends:
|
||||||
|
- TCP server for hosting SCPI instruments
|
||||||
- TCP sockets (for simulation server)
|
- TCP sockets (for simulation server)
|
||||||
- PyVISA (for real instruments)
|
- 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
|
||||||
237
src/py_dvt_ate/instruments/transport/server.py
Normal file
237
src/py_dvt_ate/instruments/transport/server.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
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})"
|
||||||
@@ -2,4 +2,10 @@
|
|||||||
|
|
||||||
Provides virtual instruments backed by a coupled thermal-electrical
|
Provides virtual instruments backed by a coupled thermal-electrical
|
||||||
physics engine. Used for development and testing without real hardware.
|
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
|
Defines the interface that all DUT models must implement to integrate
|
||||||
with the physics engine.
|
with the physics engine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
class DUTModel(ABC):
|
||||||
class DUTModel(Protocol):
|
"""Abstract base class for DUT electrical/thermal models.
|
||||||
"""Protocol for DUT electrical/thermal models.
|
|
||||||
|
|
||||||
DUT models encapsulate the temperature-dependent electrical behaviour
|
DUT models encapsulate the temperature-dependent electrical behaviour
|
||||||
of a device, enabling realistic simulation of thermal-electrical coupling.
|
of a device, enabling realistic simulation of thermal-electrical coupling.
|
||||||
@@ -18,8 +17,12 @@ class DUTModel(Protocol):
|
|||||||
All voltage parameters are in volts.
|
All voltage parameters are in volts.
|
||||||
All current parameters are in amps.
|
All current parameters are in amps.
|
||||||
All power parameters are in watts.
|
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:
|
def calculate_output_voltage(self, junction_temperature: float) -> float:
|
||||||
"""Calculate the output voltage at the given junction temperature.
|
"""Calculate the output voltage at the given junction temperature.
|
||||||
|
|
||||||
@@ -29,8 +32,9 @@ class DUTModel(Protocol):
|
|||||||
Returns:
|
Returns:
|
||||||
Output voltage in volts.
|
Output voltage in volts.
|
||||||
"""
|
"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def calculate_quiescent_current(self, junction_temperature: float) -> float:
|
def calculate_quiescent_current(self, junction_temperature: float) -> float:
|
||||||
"""Calculate the quiescent current at the given junction temperature.
|
"""Calculate the quiescent current at the given junction temperature.
|
||||||
|
|
||||||
@@ -40,8 +44,9 @@ class DUTModel(Protocol):
|
|||||||
Returns:
|
Returns:
|
||||||
Quiescent current in amps.
|
Quiescent current in amps.
|
||||||
"""
|
"""
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def calculate_power_dissipation(
|
def calculate_power_dissipation(
|
||||||
self,
|
self,
|
||||||
input_voltage: float,
|
input_voltage: float,
|
||||||
@@ -58,4 +63,4 @@ class DUTModel(Protocol):
|
|||||||
Returns:
|
Returns:
|
||||||
Power dissipation in watts.
|
Power dissipation in watts.
|
||||||
"""
|
"""
|
||||||
...
|
pass
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ and power dissipation calculations.
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.physics.models.base import DUTModel
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LDOParameters:
|
class LDOParameters:
|
||||||
@@ -35,7 +37,7 @@ class LDOParameters:
|
|||||||
REFERENCE_TEMPERATURE_C = 25.0
|
REFERENCE_TEMPERATURE_C = 25.0
|
||||||
|
|
||||||
|
|
||||||
class LDOModel:
|
class LDOModel(DUTModel):
|
||||||
"""Temperature-dependent LDO voltage regulator model.
|
"""Temperature-dependent LDO voltage regulator model.
|
||||||
|
|
||||||
Models the electrical behaviour of a linear voltage regulator with:
|
Models the electrical behaviour of a linear voltage regulator with:
|
||||||
@@ -44,7 +46,7 @@ class LDOModel:
|
|||||||
- Dropout voltage that increases with temperature
|
- Dropout voltage that increases with temperature
|
||||||
- Power dissipation from (Vin - Vout) × Iload + Vin × Iq
|
- Power dissipation from (Vin - Vout) × Iload + Vin × Iq
|
||||||
|
|
||||||
This class implements the DUTModel protocol.
|
This class implements the DUTModel interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -194,7 +196,7 @@ class LDOModel:
|
|||||||
# Temperature ratio (reference is approximately 300K ≈ 27°C)
|
# Temperature ratio (reference is approximately 300K ≈ 27°C)
|
||||||
temp_ratio = t_kelvin / 300.0
|
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:
|
def is_in_dropout(self, junction_temperature: float) -> bool:
|
||||||
"""Check if the LDO is in dropout at current operating point.
|
"""Check if the LDO is in dropout at current operating point.
|
||||||
|
|||||||
240
src/py_dvt_ate/simulation/server.py
Normal file
240
src/py_dvt_ate/simulation/server.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if server is currently running."""
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def physics_engine(self) -> PhysicsEngine | None:
|
||||||
|
"""Get the physics engine instance."""
|
||||||
|
return self._physics_engine
|
||||||
|
|
||||||
|
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:
|
||||||
|
self._physics_engine.step()
|
||||||
|
# Sleep for the physics timestep
|
||||||
|
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.
|
||||||
|
"""
|
||||||
143
src/py_dvt_ate/simulation/virtual/chamber.py
Normal file
143
src/py_dvt_ate/simulation/virtual/chamber.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset chamber to default state."""
|
||||||
|
self._setpoint = 25.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"
|
||||||
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}μV "
|
||||||
|
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."""
|
"""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."""
|
||||||
1
tests/integration/conftest.py
Normal file
1
tests/integration/conftest.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Configuration for integration tests."""
|
||||||
272
tests/integration/test_tcp_server.py
Normal file
272
tests/integration/test_tcp_server.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"""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()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
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()
|
||||||
267
tests/integration/test_tempco.py
Normal file
267
tests/integration/test_tempco.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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, SimulationServer
|
||||||
|
from py_dvt_ate.tests.thermal.tempco import TempCoTest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="function")
|
||||||
|
class TestTempCoIntegration:
|
||||||
|
"""Integration tests for TempCo test with simulator."""
|
||||||
|
|
||||||
|
async def test_tempco_runs_successfully(self, tmp_path: Path) -> None:
|
||||||
|
"""Test TempCo test runs end-to-end with simulator."""
|
||||||
|
# Start simulation server
|
||||||
|
server_config = ServerConfig(
|
||||||
|
host="127.0.0.1",
|
||||||
|
chamber_port=17000,
|
||||||
|
psu_port=17001,
|
||||||
|
dmm_port=17002,
|
||||||
|
physics_rate_hz=100.0,
|
||||||
|
)
|
||||||
|
server = SimulationServer(server_config)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create instrument set connected to simulator
|
||||||
|
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
|
||||||
|
instruments.chamber.set_ramp_rate(10.0) # Fast ramp for testing
|
||||||
|
instruments.psu.enable_output(1, False) # Ensure off initially
|
||||||
|
|
||||||
|
# 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": [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)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
async def test_tempco_with_minimal_config(self, tmp_path: Path) -> None:
|
||||||
|
"""Test TempCo uses default configuration when not specified."""
|
||||||
|
# Start simulation server
|
||||||
|
server_config = ServerConfig(
|
||||||
|
host="127.0.0.1",
|
||||||
|
chamber_port=17100,
|
||||||
|
psu_port=17101,
|
||||||
|
dmm_port=17102,
|
||||||
|
)
|
||||||
|
server = SimulationServer(server_config)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create instrument set
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
await server.stop()
|
||||||
|
|
||||||
|
async def test_tempco_handles_errors_gracefully(self, tmp_path: Path) -> None:
|
||||||
|
"""Test TempCo returns ERROR status when instruments fail."""
|
||||||
|
# Start simulation server
|
||||||
|
server_config = ServerConfig(
|
||||||
|
host="127.0.0.1",
|
||||||
|
chamber_port=17200,
|
||||||
|
psu_port=17201,
|
||||||
|
dmm_port=17202,
|
||||||
|
)
|
||||||
|
server = SimulationServer(server_config)
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create instrument set
|
||||||
|
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)
|
||||||
|
# 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:
|
||||||
|
await server.stop()
|
||||||
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,
|
||||||
|
IPowerSupply,
|
||||||
|
IThermalChamber,
|
||||||
|
InstrumentConfig,
|
||||||
|
InstrumentFactory,
|
||||||
|
InstrumentSet,
|
||||||
|
)
|
||||||
|
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
|
# Should not raise
|
||||||
hash(state)
|
hash(state)
|
||||||
{state} # Can be added to a set
|
_ = {state} # Can be added to a set
|
||||||
|
|
||||||
|
|
||||||
class TestElectricalState:
|
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)
|
||||||
295
tests/unit/test_repository.py
Normal file
295
tests/unit/test_repository.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""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."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test.db"
|
||||||
|
yield db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(temp_db):
|
||||||
|
"""Create a repository instance for testing."""
|
||||||
|
return SQLiteRepository(temp_db)
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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