Compare commits
36 Commits
v0.0.1
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e9c0ae0e5 | |||
| a742d57a6f | |||
| 2d358062f4 | |||
| 1a489b9106 | |||
| f9e59da32b | |||
| a4c01c856d | |||
| 144e80f87a | |||
| e811b21082 | |||
| 9a88a35cc5 | |||
| b31324a42a | |||
| 008134844d | |||
| ae85948539 | |||
| bccb8cc420 | |||
| 510e1ba683 | |||
| 5e69085875 | |||
| 5053399851 | |||
| d54ada18b2 | |||
| 252c329562 | |||
| 6e7da7f382 | |||
| 75e0a1cc25 | |||
| 1c0d2ead54 | |||
| 2b78a75f51 | |||
| 15c9033153 | |||
| 0ab1181ec4 | |||
| bb3129e69b | |||
| 14858a087c | |||
| 7ecdbe007a | |||
| 568d1a6ca4 | |||
| 3db4969e44 | |||
| 8ef8c18e50 | |||
| eb13bb5bc4 | |||
| ca4613e318 | |||
| 7ca31c9c97 | |||
| 13d53d13df | |||
| 6a937876a3 | |||
| 85024f8670 |
54
CHANGELOG.md
54
CHANGELOG.md
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0-alpha.3] - 2025-12-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Async TCP server for exposing virtual instruments over network
|
||||||
|
- InstrumentServer class with multi-port, multi-client support
|
||||||
|
- Line-based SCPI protocol (newline-terminated commands/responses)
|
||||||
|
- SimulationServer wiring physics engine to all virtual instruments
|
||||||
|
- CLI `serve` command to start simulation server with configurable ports
|
||||||
|
- Integration tests for TCP server and instrument connectivity
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- SCPI foundation (Sprint 5): command parser with IEEE 488.2 support
|
||||||
|
- Virtual instrument base class with command dispatch
|
||||||
|
- Thermal chamber simulator (TEMP:SETPOINT, TEMP:ACTUAL?, TEMP:STAB?)
|
||||||
|
- Power supply simulator (VOLT, CURR, OUTP, MEAS commands)
|
||||||
|
- Multimeter simulator (MEAS:VOLT:DC?, MEAS:CURR:DC?, CONF, READ?)
|
||||||
|
|
||||||
|
## [0.1.0-alpha.2] - 2025-12-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Streamlit dashboard for interactive physics visualisation
|
||||||
|
- Real-time temperature charts (chamber, case, junction)
|
||||||
|
- Current state metrics display (voltages, currents, power, temperatures)
|
||||||
|
- Interactive controls in sidebar:
|
||||||
|
- Temperature setpoint slider (-40°C to 125°C)
|
||||||
|
- Input voltage slider (0-12V)
|
||||||
|
- Load current slider (0-500mA)
|
||||||
|
- Output enable toggle
|
||||||
|
- Start/Stop/Reset simulation buttons
|
||||||
|
- Self-heating demonstration panel with:
|
||||||
|
- Junction-case and case-ambient temperature rise display
|
||||||
|
- Power dissipation chart
|
||||||
|
- Thermal coupling explanation
|
||||||
|
|
||||||
|
## [0.1.0-alpha.1] - 2025-12-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Physics engine with thermal-electrical coupling
|
||||||
|
- First-order thermal response calculations for chamber and case
|
||||||
|
- Junction temperature calculation via thermal resistance (θ_jc)
|
||||||
|
- Self-heating effects from power dissipation
|
||||||
|
- LDO DUT model with temperature-dependent behaviour
|
||||||
|
- Output voltage temperature coefficient (ppm/°C)
|
||||||
|
- Quiescent current temperature coefficient
|
||||||
|
- Dropout voltage temperature dependence
|
||||||
|
- Power dissipation calculation
|
||||||
|
- Comprehensive physics engine test suite (13 tests)
|
||||||
|
|
||||||
## [0.0.1] - 2025-12-01
|
## [0.0.1] - 2025-12-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -28,7 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
| 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 | TBD | First DVT test runs |
|
||||||
| 0.1.0-beta.1 | TBD | HAL complete |
|
| 0.1.0-beta.1 | TBD | 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 | TBD | Visual demo |
|
| 0.1.0-alpha.2 | 2025-12-02 | Visual demo |
|
||||||
| 0.1.0-alpha.1 | TBD | 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 |
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ For **why** decisions were made, see `03_architecture_decisions.md`.
|
|||||||
### 2.1 Directory Layout
|
### 2.1 Directory Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
thermaulate/
|
py_dvt_ate/
|
||||||
├── pyproject.toml # Project metadata and dependencies
|
├── pyproject.toml # Project metadata and dependencies
|
||||||
├── README.md # Project overview and quick start
|
├── README.md # Project overview and quick start
|
||||||
├── CHANGELOG.md # Version history
|
├── CHANGELOG.md # Version history
|
||||||
@@ -222,205 +222,163 @@ thermaulate/
|
|||||||
├── docs/
|
├── docs/
|
||||||
│ ├── 01_requirements.md # Business Requirements
|
│ ├── 01_requirements.md # Business Requirements
|
||||||
│ ├── 02_technical_specification.md # Technical Design (this doc)
|
│ ├── 02_technical_specification.md # Technical Design (this doc)
|
||||||
│ └── 03_architecture_decisions.md # Architecture Decisions
|
│ ├── 03_architecture_decisions.md # Architecture Decisions
|
||||||
|
│ └── 04_development_plan.md # Sprint breakdown
|
||||||
│
|
│
|
||||||
├── src/
|
├── src/py_dvt_ate/
|
||||||
│ └── thermaulate/
|
│ ├── __init__.py # Package version
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── py.typed # PEP 561 marker
|
│ ├── py.typed # PEP 561 marker
|
||||||
│ │
|
│ │
|
||||||
│ ├── physics/ # Physics simulation engine
|
│ ├── instruments/ # INSTRUMENT CONTROL (reusable)
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── engine.py # Main physics loop
|
│ │ ├── interfaces.py # IThermalChamber, IPowerSupply, IMultimeter
|
||||||
│ │ ├── thermal.py # Thermal domain model
|
│ │ ├── scpi.py # SCPI parser (shared protocol)
|
||||||
│ │ ├── electrical.py # Electrical domain model
|
│ │ ├── factory.py # Creates instrument sets from config
|
||||||
│ │ └── dut/
|
│ │ ├── transport/ # Connection layer
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── base.py # Transport protocol
|
||||||
|
│ │ │ ├── tcp.py # TCP socket transport
|
||||||
|
│ │ │ └── visa.py # PyVISA transport (future)
|
||||||
|
│ │ └── drivers/ # SCPI driver implementations
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── base.py # DUT base class
|
│ │ ├── base.py # Base driver
|
||||||
│ │ └── ldo.py # LDO voltage regulator model
|
│ │ ├── chamber.py # Thermal chamber driver
|
||||||
|
│ │ ├── power_supply.py # PSU driver
|
||||||
|
│ │ └── multimeter.py # DMM driver
|
||||||
│ │
|
│ │
|
||||||
│ ├── instruments/ # Virtual instrument implementations
|
│ ├── simulation/ # PHYSICS SIMULATION (dev/test only)
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── base.py # Instrument base class
|
│ │ ├── server.py # TCP server hosting virtual instruments
|
||||||
│ │ ├── scpi_parser.py # SCPI command parser
|
│ │ ├── physics/ # Physics engine
|
||||||
│ │ ├── thermal_chamber.py # Thermal chamber simulator
|
│ │ │ ├── __init__.py
|
||||||
│ │ ├── power_supply.py # Power supply simulator
|
│ │ │ ├── engine.py # Main simulation loop
|
||||||
│ │ └── multimeter.py # DMM simulator
|
│ │ │ ├── thermal.py # Thermal calculations
|
||||||
|
│ │ │ └── models/ # DUT models
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── base.py # DUT protocol
|
||||||
|
│ │ │ └── ldo.py # LDO model
|
||||||
|
│ │ └── virtual/ # Virtual instrument implementations
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── base.py # Base virtual instrument
|
||||||
|
│ │ ├── chamber.py # Virtual thermal chamber
|
||||||
|
│ │ ├── power_supply.py # Virtual PSU
|
||||||
|
│ │ └── multimeter.py # Virtual DMM
|
||||||
│ │
|
│ │
|
||||||
│ ├── server/ # Simulation server
|
│ ├── framework/ # TEST FRAMEWORK (reusable)
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── tcp_server.py # Async TCP server
|
│ │ ├── runner.py # Test sequencer
|
||||||
│ │ └── main.py # Server entry point
|
│ │ ├── context.py # Runtime context
|
||||||
|
│ │ ├── logger.py # Measurement logging
|
||||||
|
│ │ ├── limits.py # Pass/fail evaluation
|
||||||
|
│ │ └── models.py # Framework models
|
||||||
│ │
|
│ │
|
||||||
│ ├── transport/ # Communication layer
|
│ ├── tests/ # DVT TEST IMPLEMENTATIONS
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── base.py # Transport protocol
|
│ │ ├── base.py # Base test class
|
||||||
│ │ ├── tcp.py # TCP/IP implementation
|
│ │ ├── thermal/ # Thermal characterisation tests
|
||||||
│ │ └── async_tcp.py # Async TCP implementation
|
│ │ │ ├── __init__.py
|
||||||
│ │
|
│ │ │ └── tempco.py # Temperature coefficient test
|
||||||
│ ├── drivers/ # Instrument SCPI drivers
|
│ │ └── electrical/ # Electrical characterisation tests
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── base.py # Driver base class
|
|
||||||
│ │ ├── thermal_chamber.py # Chamber SCPI driver
|
|
||||||
│ │ ├── power_supply.py # PSU SCPI driver
|
|
||||||
│ │ └── multimeter.py # DMM SCPI driver
|
|
||||||
│ │
|
|
||||||
│ ├── hal/ # Hardware Abstraction Layer
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── interfaces.py # Protocol definitions
|
|
||||||
│ │ ├── factory.py # Instrument factory
|
|
||||||
│ │ └── impl/ # HAL implementations
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── thermal_chamber.py
|
|
||||||
│ │ ├── power_supply.py
|
|
||||||
│ │ └── multimeter.py
|
|
||||||
│ │
|
|
||||||
│ ├── executive/ # Test execution framework
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── sequencer.py # Test sequencer
|
|
||||||
│ │ ├── context.py # Test context
|
|
||||||
│ │ ├── logger.py # Test logger
|
|
||||||
│ │ ├── limits.py # Limit checker
|
|
||||||
│ │ └── models.py # Domain models
|
|
||||||
│ │
|
|
||||||
│ ├── tests/ # DVT test implementations
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── base.py # Test base class
|
|
||||||
│ │ ├── tempco.py # TempCo characterisation
|
|
||||||
│ │ └── load_regulation.py # Load regulation test
|
│ │ └── load_regulation.py # Load regulation test
|
||||||
│ │
|
│ │
|
||||||
│ ├── data/ # Data persistence
|
│ ├── data/ # DATA PERSISTENCE (shared)
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── repository.py # Data access layer
|
│ │ ├── repository.py # Data access layer
|
||||||
│ │ ├── models.py # Data models
|
│ │ └── models.py # Data models
|
||||||
│ │ └── migrations/ # Schema migrations
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── reporting/ # Report generation (Phase 3)
|
│ ├── reporting/ # REPORT GENERATION (standalone)
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── generator.py # Report generator
|
│ │ ├── generator.py # Report generator
|
||||||
│ │ ├── pdf.py # PDF output
|
|
||||||
│ │ ├── html.py # HTML output
|
|
||||||
│ │ └── templates/ # Report templates
|
│ │ └── templates/ # Report templates
|
||||||
│ │
|
│ │
|
||||||
│ ├── api/ # REST API (Phase 2)
|
│ └── app/ # APPLICATION ENTRY POINTS
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── main.py # FastAPI app
|
|
||||||
│ │ └── routes/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── instruments.py
|
|
||||||
│ │ ├── tests.py
|
|
||||||
│ │ └── runs.py
|
|
||||||
│ │
|
|
||||||
│ ├── dashboard/ # Streamlit dashboard
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── app.py # Main Streamlit app
|
|
||||||
│ │ ├── pages/ # Multi-page app
|
|
||||||
│ │ │ ├── 01_instruments.py
|
|
||||||
│ │ │ ├── 02_run_test.py
|
|
||||||
│ │ │ └── 03_results.py
|
|
||||||
│ │ └── components/ # Reusable UI components
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── instrument_panel.py
|
|
||||||
│ │
|
|
||||||
│ ├── cli/ # Command-line interface
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── main.py # Typer CLI app
|
|
||||||
│ │
|
|
||||||
│ └── config/ # Configuration handling
|
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── models.py # Pydantic config models
|
│ ├── cli.py # Command-line interface
|
||||||
│ └── loader.py # Config file loader
|
│ ├── config.py # YAML loading
|
||||||
|
│ └── dashboard/ # Streamlit dashboard
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── app.py # Main Streamlit app
|
||||||
│
|
│
|
||||||
├── tests/ # Test suite
|
├── tests/ # pytest test suite
|
||||||
│ ├── conftest.py # pytest fixtures
|
│ ├── conftest.py # pytest fixtures
|
||||||
│ ├── unit/
|
│ ├── unit/ # Unit tests
|
||||||
│ │ ├── test_physics_engine.py
|
│ └── integration/ # Integration tests
|
||||||
│ │ ├── test_scpi_parser.py
|
|
||||||
│ │ ├── test_thermal_model.py
|
|
||||||
│ │ └── ...
|
|
||||||
│ └── integration/
|
|
||||||
│ ├── test_instrument_communication.py
|
|
||||||
│ ├── test_tempco_sequence.py
|
|
||||||
│ └── ...
|
|
||||||
│
|
│
|
||||||
├── config/ # Configuration files
|
├── config/ # Configuration files
|
||||||
│ ├── default.yaml # Default configuration
|
│ └── default.yaml # Default configuration
|
||||||
│ └── example_pyvisa.yaml # Example for real hardware
|
|
||||||
│
|
│
|
||||||
├── docker/
|
└── docker/ # Docker deployment
|
||||||
│ ├── Dockerfile.server # Simulation server image
|
├── Dockerfile.server # Simulation server image
|
||||||
│ ├── Dockerfile.app # Test application image
|
├── Dockerfile.app # Test application image
|
||||||
│ └── docker-compose.yml # Full stack orchestration
|
└── docker-compose.yml # Full stack orchestration
|
||||||
│
|
|
||||||
└── scripts/
|
|
||||||
├── demo.py # Demo script
|
|
||||||
└── run_tempco.py # Example test execution
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 Package Dependencies
|
### 2.2 Package Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
thermaulate/
|
Dependency Graph:
|
||||||
├── cli/ ──────────────────────────────────────────────┐
|
|
||||||
├── api/ ──────────────────────────────────────────────┤
|
|
||||||
├── dashboard/ ──────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ PRESENTATION │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
├── executive/ ◄───────────────────────────────────────────────┤
|
|
||||||
├── tests/ ◄───────────────────────────────────────────────┤
|
|
||||||
├── reporting/ ◄───────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ APPLICATION │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
├── hal/interfaces ◄───────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ DOMAIN (Abstractions) │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ implements│ │
|
|
||||||
│ ▼ │
|
|
||||||
├── hal/impl ◄───────────────────────────────────────────────┤
|
|
||||||
├── drivers/ ◄───────────────────────────────────────────────┤
|
|
||||||
├── transport/ ◄───────────────────────────────────────────────┤
|
|
||||||
├── data/ ◄───────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ INFRASTRUCTURE │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
SIMULATION SERVER (Separate Process):
|
app/ ──────────────▶ framework/ ──────────────▶ instruments/
|
||||||
├── physics/ ◄─── Pure domain logic, no external dependencies
|
│ │ │
|
||||||
├── instruments/ ◄─── Depends on physics
|
│ ▼ │
|
||||||
└── server/ ◄─── Depends on instruments
|
│ data/ ◀────────────────────────┘
|
||||||
|
│ ▲
|
||||||
|
▼ │
|
||||||
|
reporting/ ──────────────┘
|
||||||
|
|
||||||
|
simulation/ ─────────────────────────────────▶ instruments/
|
||||||
|
|
||||||
|
Key:
|
||||||
|
- app/ : CLI, dashboard, config loading (PRESENTATION)
|
||||||
|
- framework/ : Test runner, logger, limits (APPLICATION)
|
||||||
|
- instruments/ : Interfaces, drivers, transport, SCPI (DOMAIN)
|
||||||
|
- data/ : Persistence layer (INFRASTRUCTURE)
|
||||||
|
- reporting/ : Report generation (standalone)
|
||||||
|
- simulation/ : Physics engine, virtual instruments (DEVELOPMENT)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Module Specifications
|
## 3. Module Specifications
|
||||||
|
|
||||||
### 3.1 Physics Module
|
### 3.1 Instruments Package
|
||||||
|
|
||||||
**Responsibility**: Simulate coupled thermal-electrical behaviour.
|
**Responsibility**: Everything about talking to lab instruments.
|
||||||
|
|
||||||
**Key Components**:
|
**Key Components**:
|
||||||
|
|
||||||
| Component | File | Purpose |
|
| Component | File | Purpose |
|
||||||
|-----------|------|---------|
|
|-----------|------|---------|
|
||||||
| PhysicsEngine | `engine.py` | Main simulation loop, state management |
|
| Interfaces | `instruments/interfaces.py` | IThermalChamber, IPowerSupply, IMultimeter protocols |
|
||||||
| ThermalModel | `thermal.py` | Heat transfer calculations |
|
| SCPIParser | `instruments/scpi.py` | Parse SCPI command strings |
|
||||||
| ElectricalModel | `electrical.py` | Current/voltage relationships |
|
| Factory | `instruments/factory.py` | Create instrument sets from config |
|
||||||
| DUTBase | `dut/base.py` | Abstract DUT interface |
|
| Transport | `instruments/transport/` | TCP, VISA connection layer |
|
||||||
| LDOModel | `dut/ldo.py` | LDO voltage regulator implementation |
|
| Drivers | `instruments/drivers/` | SCPI command implementations |
|
||||||
|
|
||||||
|
**Command Processing Flow**:
|
||||||
|
```
|
||||||
|
High-level call → Driver → SCPI command → Transport → Instrument
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Simulation Package
|
||||||
|
|
||||||
|
**Responsibility**: Physics simulation for development without real hardware.
|
||||||
|
|
||||||
|
**Key Components**:
|
||||||
|
|
||||||
|
| Component | File | Purpose |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Server | `simulation/server.py` | TCP server hosting virtual instruments |
|
||||||
|
| PhysicsEngine | `simulation/physics/engine.py` | Main simulation loop |
|
||||||
|
| ThermalModel | `simulation/physics/thermal.py` | Heat transfer calculations |
|
||||||
|
| DUTBase | `simulation/physics/models/base.py` | Abstract DUT interface |
|
||||||
|
| LDOModel | `simulation/physics/models/ldo.py` | LDO voltage regulator model |
|
||||||
|
| VirtualChamber | `simulation/virtual/chamber.py` | Virtual thermal chamber |
|
||||||
|
| VirtualPSU | `simulation/virtual/power_supply.py` | Virtual power supply |
|
||||||
|
| VirtualDMM | `simulation/virtual/multimeter.py` | Virtual multimeter |
|
||||||
|
|
||||||
**State Management**:
|
**State Management**:
|
||||||
- Engine maintains global simulation time
|
- Engine maintains global simulation time
|
||||||
@@ -429,108 +387,68 @@ SIMULATION SERVER (Separate Process):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.2 Instruments Module
|
### 3.3 Framework Package
|
||||||
|
|
||||||
**Responsibility**: SCPI-compliant virtual instrument behaviour.
|
**Responsibility**: Test execution infrastructure.
|
||||||
|
|
||||||
**Key Components**:
|
**Key Components**:
|
||||||
|
|
||||||
| Component | File | Purpose |
|
| Component | File | Purpose |
|
||||||
|-----------|------|---------|
|
|-----------|------|---------|
|
||||||
| InstrumentBase | `base.py` | Common instrument functionality |
|
| TestRunner | `framework/runner.py` | Sequences test steps |
|
||||||
| SCPIParser | `scpi_parser.py` | Parse SCPI command strings |
|
| TestContext | `framework/context.py` | Runtime context |
|
||||||
| ThermalChamberSim | `thermal_chamber.py` | Chamber simulation |
|
| TestLogger | `framework/logger.py` | Measurement logging |
|
||||||
| PowerSupplySim | `power_supply.py` | PSU simulation |
|
| LimitChecker | `framework/limits.py` | Pass/fail evaluation |
|
||||||
| MultimeterSim | `multimeter.py` | DMM simulation |
|
| Models | `framework/models.py` | TestStatus, TestResult, etc. |
|
||||||
|
|
||||||
**Command Processing Flow**:
|
|
||||||
```
|
|
||||||
SCPI String → Parser → Command Object → Instrument Handler → Response
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.3 Transport Module
|
### 3.4 Data Package
|
||||||
|
|
||||||
**Responsibility**: Low-level communication.
|
**Responsibility**: Data persistence for test results.
|
||||||
|
|
||||||
**Key Components**:
|
**Key Components**:
|
||||||
|
|
||||||
| Component | File | Purpose |
|
| Component | File | Purpose |
|
||||||
|-----------|------|---------|
|
|-----------|------|---------|
|
||||||
| Transport Protocol | `base.py` | Abstract transport interface |
|
| Repository | `data/repository.py` | Data access layer |
|
||||||
| TCPTransport | `tcp.py` | Synchronous TCP implementation |
|
| Models | `data/models.py` | TestRun, Measurement dataclasses |
|
||||||
| AsyncTCPTransport | `async_tcp.py` | Async TCP implementation |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.4 Drivers Module
|
### 3.5 Reporting Package
|
||||||
|
|
||||||
**Responsibility**: Instrument-specific SCPI command sets.
|
**Responsibility**: Report generation from stored data.
|
||||||
|
|
||||||
**Key Components**:
|
**Key Components**:
|
||||||
|
|
||||||
| Component | File | Purpose |
|
| Component | File | Purpose |
|
||||||
|-----------|------|---------|
|
|-----------|------|---------|
|
||||||
| DriverBase | `base.py` | Common driver functionality |
|
| Generator | `reporting/generator.py` | Creates reports from data |
|
||||||
| ThermalChamberDriver | `thermal_chamber.py` | Chamber SCPI commands |
|
| Templates | `reporting/templates/` | Report templates |
|
||||||
| PowerSupplyDriver | `power_supply.py` | PSU SCPI commands |
|
|
||||||
| MultimeterDriver | `multimeter.py` | DMM SCPI commands |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.5 HAL Module
|
### 3.6 App Package
|
||||||
|
|
||||||
**Responsibility**: Hardware abstraction interfaces.
|
**Responsibility**: Application entry points.
|
||||||
|
|
||||||
**Key Components**:
|
**Key Components**:
|
||||||
|
|
||||||
| Component | File | Purpose |
|
| Component | File | Purpose |
|
||||||
|-----------|------|---------|
|
|-----------|------|---------|
|
||||||
| Protocols | `interfaces.py` | Abstract interfaces |
|
| CLI | `app/cli.py` | Command-line interface (Typer) |
|
||||||
| InstrumentFactory | `factory.py` | Creates instrument sets from config |
|
| Config | `app/config.py` | YAML loading, instance creation |
|
||||||
| HAL Implementations | `impl/*.py` | Concrete HAL classes |
|
| Dashboard | `app/dashboard/app.py` | Streamlit application |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.6 Executive Module
|
|
||||||
|
|
||||||
**Responsibility**: Test execution orchestration.
|
|
||||||
|
|
||||||
**Key Components**:
|
|
||||||
|
|
||||||
| Component | File | Purpose |
|
|
||||||
|-----------|------|---------|
|
|
||||||
| TestSequencer | `sequencer.py` | Run test sequences |
|
|
||||||
| TestContext | `context.py` | Runtime context |
|
|
||||||
| TestLogger | `logger.py` | Measurement logging |
|
|
||||||
| LimitChecker | `limits.py` | Pass/fail evaluation |
|
|
||||||
| Domain Models | `models.py` | Measurement, Result, etc. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.7 Dashboard Module
|
|
||||||
|
|
||||||
**Responsibility**: Real-time visualisation via Streamlit.
|
|
||||||
|
|
||||||
**Key Components**:
|
|
||||||
|
|
||||||
| Component | File | Purpose |
|
|
||||||
|-----------|------|---------|
|
|
||||||
| Main App | `app.py` | Streamlit application entry point |
|
|
||||||
| Instruments Page | `pages/01_instruments.py` | Live instrument status |
|
|
||||||
| Run Test Page | `pages/02_run_test.py` | Test execution interface |
|
|
||||||
| Results Page | `pages/03_results.py` | Historical results viewer |
|
|
||||||
| Instrument Panel | `components/instrument_panel.py` | Reusable instrument display |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Interface Definitions
|
## 4. Interface Definitions
|
||||||
|
|
||||||
### 4.1 HAL Interfaces
|
### 4.1 Instrument Interfaces
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/hal/interfaces.py
|
# py_dvt_ate/instruments/interfaces.py
|
||||||
|
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
@@ -664,7 +582,7 @@ class ITestLogger(Protocol):
|
|||||||
### 4.2 Transport Interface
|
### 4.2 Transport Interface
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/transport/base.py
|
# py_dvt_ate/instruments/transport/base.py
|
||||||
|
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
@@ -701,7 +619,7 @@ class Transport(Protocol):
|
|||||||
### 4.3 Test Interface
|
### 4.3 Test Interface
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/executive/models.py
|
# py_dvt_ate/framework/models.py
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -778,12 +696,12 @@ class ITest(Protocol):
|
|||||||
### 4.4 Factory Interface
|
### 4.4 Factory Interface
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/hal/factory.py
|
# py_dvt_ate/instruments/factory.py
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from thermaulate.hal.interfaces import IThermalChamber, IPowerSupply, IMultimeter
|
from py_dvt_ate.instruments.interfaces import IThermalChamber, IPowerSupply, IMultimeter
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -827,22 +745,19 @@ class InstrumentFactory:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _create_simulated(config: InstrumentConfig) -> InstrumentSet:
|
def _create_simulated(config: InstrumentConfig) -> InstrumentSet:
|
||||||
"""Create simulated instruments."""
|
"""Create simulated instruments."""
|
||||||
from thermaulate.transport.tcp import TCPTransport
|
from py_dvt_ate.instruments.transport.tcp import TCPTransport
|
||||||
from thermaulate.drivers.thermal_chamber import ThermalChamberDriver
|
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
|
||||||
from thermaulate.drivers.power_supply import PowerSupplyDriver
|
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
|
||||||
from thermaulate.drivers.multimeter import MultimeterDriver
|
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
|
||||||
from thermaulate.hal.impl.thermal_chamber import ThermalChamberHAL
|
|
||||||
from thermaulate.hal.impl.power_supply import PowerSupplyHAL
|
|
||||||
from thermaulate.hal.impl.multimeter import MultimeterHAL
|
|
||||||
|
|
||||||
chamber_transport = TCPTransport(config.simulator_host, config.chamber_port)
|
chamber_transport = TCPTransport(config.simulator_host, config.chamber_port)
|
||||||
psu_transport = TCPTransport(config.simulator_host, config.psu_port)
|
psu_transport = TCPTransport(config.simulator_host, config.psu_port)
|
||||||
dmm_transport = TCPTransport(config.simulator_host, config.dmm_port)
|
dmm_transport = TCPTransport(config.simulator_host, config.dmm_port)
|
||||||
|
|
||||||
return InstrumentSet(
|
return InstrumentSet(
|
||||||
chamber=ThermalChamberHAL(ThermalChamberDriver(chamber_transport)),
|
chamber=ThermalChamberDriver(chamber_transport),
|
||||||
psu=PowerSupplyHAL(PowerSupplyDriver(psu_transport)),
|
psu=PowerSupplyDriver(psu_transport),
|
||||||
dmm=MultimeterHAL(MultimeterDriver(dmm_transport)),
|
dmm=MultimeterDriver(dmm_transport),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -954,7 +869,7 @@ All instruments implement these standard commands:
|
|||||||
### 5.5 SCPI Parser Specification
|
### 5.5 SCPI Parser Specification
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/instruments/scpi_parser.py
|
# py_dvt_ate/instruments/scpi.py
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -1071,7 +986,7 @@ P_diss = (V_in - V_out) × I_load + V_in × I_q
|
|||||||
### 6.3 Physics Engine Implementation
|
### 6.3 Physics Engine Implementation
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/physics/engine.py
|
# py_dvt_ate/simulation/physics/engine.py
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -1256,7 +1171,7 @@ Schema:
|
|||||||
### 7.3 Data Repository Interface
|
### 7.3 Data Repository Interface
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/data/repository.py
|
# py_dvt_ate/data/repository.py (interface)
|
||||||
|
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -1366,14 +1281,14 @@ dut:
|
|||||||
|
|
||||||
# Data storage paths
|
# Data storage paths
|
||||||
data:
|
data:
|
||||||
database_path: "./data/thermaulate.db"
|
database_path: "./data/py_dvt_ate.db"
|
||||||
measurements_dir: "./data/measurements"
|
measurements_dir: "./data/measurements"
|
||||||
reports_dir: "./data/reports"
|
reports_dir: "./data/reports"
|
||||||
|
|
||||||
# Logging configuration
|
# Logging configuration
|
||||||
logging:
|
logging:
|
||||||
level: INFO
|
level: INFO
|
||||||
file: "./data/logs/thermaulate.log"
|
file: "./data/logs/py_dvt_ate.log"
|
||||||
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
||||||
# Dashboard (Streamlit)
|
# Dashboard (Streamlit)
|
||||||
@@ -1391,7 +1306,7 @@ api:
|
|||||||
### 8.2 Pydantic Configuration Models
|
### 8.2 Pydantic Configuration Models
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# thermaulate/config/models.py
|
# py_dvt_ate/app/config.py (config models)
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -1449,14 +1364,14 @@ class DUTConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class DataConfig(BaseModel):
|
class DataConfig(BaseModel):
|
||||||
database_path: str = "./data/thermaulate.db"
|
database_path: str = "./data/py_dvt_ate.db"
|
||||||
measurements_dir: str = "./data/measurements"
|
measurements_dir: str = "./data/measurements"
|
||||||
reports_dir: str = "./data/reports"
|
reports_dir: str = "./data/reports"
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseModel):
|
class LoggingConfig(BaseModel):
|
||||||
level: str = "INFO"
|
level: str = "INFO"
|
||||||
file: str = "./data/logs/thermaulate.log"
|
file: str = "./data/logs/py_dvt_ate.log"
|
||||||
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
||||||
|
|
||||||
@@ -1612,7 +1527,7 @@ class AppConfig(BaseModel):
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
name = "thermaulate"
|
name = "py_dvt_ate"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Coupled Physics DVT Simulation Platform"
|
description = "Coupled Physics DVT Simulation Platform"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -1648,9 +1563,9 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
thermaulate = "thermaulate.cli.main:app"
|
py_dvt_ate = "py_dvt_ate.cli.main:app"
|
||||||
thermaulate-server = "thermaulate.server.main:main"
|
py_dvt_ate-server = "py_dvt_ate.server.main:main"
|
||||||
thermaulate-dashboard = "thermaulate.dashboard.app:main"
|
py_dvt_ate-dashboard = "py_dvt_ate.dashboard.app:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Vertical Slice Strategy:**
|
**Vertical Slice Strategy:**
|
||||||
- Sprints 1-3: Foundation + Physics Engine (the core simulation)
|
- Sprints 1-3: Foundation + Physics Engine (the core simulation)
|
||||||
- Sprint 4: Dashboard (see the physics working!)
|
- Sprint 4: Dashboard (see the physics working!)
|
||||||
- Sprints 5-11: Infrastructure/Plumbing (SCPI, TCP, HAL)
|
- Sprints 5-11: Infrastructure/Plumbing (SCPI, TCP, Instruments)
|
||||||
- Sprints 12-17: Test Framework, CLI, Polish
|
- Sprints 12-17: Test Framework, CLI, Polish
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -79,20 +79,18 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Define physics interfaces and data structures.
|
**Goal:** Define physics interfaces and data structures.
|
||||||
|
|
||||||
### Task 2.1: Define thermal state dataclasses
|
### Task 2.1: Define thermal state dataclasses
|
||||||
- Create src/py_dvt_ate/physics/__init__.py
|
- Create src/py_dvt_ate/simulation/physics/models.py
|
||||||
- Create src/py_dvt_ate/physics/models.py
|
|
||||||
- Define ThermalState (frozen dataclass)
|
- Define ThermalState (frozen dataclass)
|
||||||
- Define ElectricalState (frozen dataclass)
|
- Define ElectricalState (frozen dataclass)
|
||||||
- **Commit:** "Add physics state dataclasses"
|
- **Commit:** "Add physics state dataclasses"
|
||||||
|
|
||||||
### Task 2.2: Define DUT base protocol
|
### Task 2.2: Define DUT base protocol
|
||||||
- Create src/py_dvt_ate/physics/dut/__init__.py
|
- Create src/py_dvt_ate/simulation/physics/models/base.py
|
||||||
- Create src/py_dvt_ate/physics/dut/base.py
|
|
||||||
- Define DUTModel Protocol with method signatures
|
- Define DUTModel Protocol with method signatures
|
||||||
- **Commit:** "Add DUT model protocol"
|
- **Commit:** "Add DUT model protocol"
|
||||||
|
|
||||||
### Task 2.3: Create physics engine stub
|
### Task 2.3: Create physics engine stub
|
||||||
- Create src/py_dvt_ate/physics/engine.py
|
- Create src/py_dvt_ate/simulation/physics/engine.py
|
||||||
- Define PhysicsEngine class with stub methods
|
- Define PhysicsEngine class with stub methods
|
||||||
- Methods return placeholder values
|
- Methods return placeholder values
|
||||||
- **Commit:** "Add physics engine stub"
|
- **Commit:** "Add physics engine stub"
|
||||||
@@ -109,13 +107,13 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Implement working physics simulation.
|
**Goal:** Implement working physics simulation.
|
||||||
|
|
||||||
### Task 3.1: Implement thermal calculations
|
### Task 3.1: Implement thermal calculations
|
||||||
- Create src/py_dvt_ate/physics/thermal.py
|
- Create src/py_dvt_ate/simulation/physics/thermal.py
|
||||||
- Implement first-order thermal response calculations
|
- Implement first-order thermal response calculations
|
||||||
- Pure functions, no state
|
- Pure functions, no state
|
||||||
- **Commit:** "Implement thermal calculation functions"
|
- **Commit:** "Implement thermal calculation functions"
|
||||||
|
|
||||||
### Task 3.2: Implement LDO DUT model
|
### Task 3.2: Implement LDO DUT model
|
||||||
- Create src/py_dvt_ate/physics/dut/ldo.py
|
- Create src/py_dvt_ate/simulation/physics/models/ldo.py
|
||||||
- Implement LDOModel class
|
- Implement LDOModel class
|
||||||
- Temperature-dependent Vout, Iq calculations
|
- Temperature-dependent Vout, Iq calculations
|
||||||
- Power dissipation calculation
|
- Power dissipation calculation
|
||||||
@@ -140,8 +138,8 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Visualise physics engine directly - see something working!
|
**Goal:** Visualise physics engine directly - see something working!
|
||||||
|
|
||||||
### Task 4.1: Create dashboard app skeleton
|
### Task 4.1: Create dashboard app skeleton
|
||||||
- Create src/py_dvt_ate/dashboard/__init__.py
|
- Create src/py_dvt_ate/app/dashboard/__init__.py
|
||||||
- Create src/py_dvt_ate/dashboard/app.py
|
- Create src/py_dvt_ate/app/dashboard/app.py
|
||||||
- Basic Streamlit page with title
|
- Basic Streamlit page with title
|
||||||
- **Commit:** "Add Streamlit dashboard skeleton"
|
- **Commit:** "Add Streamlit dashboard skeleton"
|
||||||
|
|
||||||
@@ -173,7 +171,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
|
|
||||||
### Task 5.1: Define SCPI command dataclass
|
### Task 5.1: Define SCPI command dataclass
|
||||||
- Create src/py_dvt_ate/instruments/__init__.py
|
- Create src/py_dvt_ate/instruments/__init__.py
|
||||||
- Create src/py_dvt_ate/instruments/scpi_parser.py
|
- Create src/py_dvt_ate/instruments/scpi.py
|
||||||
- Define SCPICommand dataclass
|
- Define SCPICommand dataclass
|
||||||
- **Commit:** "Add SCPI command dataclass"
|
- **Commit:** "Add SCPI command dataclass"
|
||||||
|
|
||||||
@@ -196,13 +194,13 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Create first virtual instrument.
|
**Goal:** Create first virtual instrument.
|
||||||
|
|
||||||
### Task 6.1: Define instrument base class
|
### Task 6.1: Define instrument base class
|
||||||
- Create src/py_dvt_ate/instruments/base.py
|
- Create src/py_dvt_ate/simulation/virtual/base.py
|
||||||
- Define BaseInstrument with common functionality
|
- Define BaseInstrument with common functionality
|
||||||
- Command dispatch mechanism
|
- Command dispatch mechanism
|
||||||
- **Commit:** "Add base instrument class"
|
- **Commit:** "Add base instrument class"
|
||||||
|
|
||||||
### Task 6.2: Create thermal chamber simulator stub
|
### Task 6.2: Create thermal chamber simulator stub
|
||||||
- Create src/py_dvt_ate/instruments/thermal_chamber.py
|
- Create src/py_dvt_ate/simulation/virtual/chamber.py
|
||||||
- Define ThermalChamberSim class
|
- Define ThermalChamberSim class
|
||||||
- Stub SCPI command handlers
|
- Stub SCPI command handlers
|
||||||
- **Commit:** "Add thermal chamber simulator stub"
|
- **Commit:** "Add thermal chamber simulator stub"
|
||||||
@@ -224,7 +222,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Complete instrument simulators.
|
**Goal:** Complete instrument simulators.
|
||||||
|
|
||||||
### Task 7.1: Create power supply simulator
|
### Task 7.1: Create power supply simulator
|
||||||
- Create src/py_dvt_ate/instruments/power_supply.py
|
- Create src/py_dvt_ate/simulation/virtual/power_supply.py
|
||||||
- Implement PSU SCPI commands
|
- Implement PSU SCPI commands
|
||||||
- VOLT, CURR, OUTP, MEAS commands
|
- VOLT, CURR, OUTP, MEAS commands
|
||||||
- **Commit:** "Add power supply simulator"
|
- **Commit:** "Add power supply simulator"
|
||||||
@@ -234,7 +232,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
- **Commit:** "Add power supply simulator tests"
|
- **Commit:** "Add power supply simulator tests"
|
||||||
|
|
||||||
### Task 7.3: Create DMM simulator
|
### Task 7.3: Create DMM simulator
|
||||||
- Create src/py_dvt_ate/instruments/multimeter.py
|
- Create src/py_dvt_ate/simulation/virtual/multimeter.py
|
||||||
- Implement DMM SCPI commands
|
- Implement DMM SCPI commands
|
||||||
- MEAS:VOLT:DC?, CONF commands
|
- MEAS:VOLT:DC?, CONF commands
|
||||||
- **Commit:** "Add multimeter simulator"
|
- **Commit:** "Add multimeter simulator"
|
||||||
@@ -250,8 +248,8 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Expose instruments over network.
|
**Goal:** Expose instruments over network.
|
||||||
|
|
||||||
### Task 8.1: Create async TCP server foundation
|
### Task 8.1: Create async TCP server foundation
|
||||||
- Create src/py_dvt_ate/server/__init__.py
|
- Create src/py_dvt_ate/simulation/__init__.py
|
||||||
- Create src/py_dvt_ate/server/tcp_server.py
|
- Create src/py_dvt_ate/simulation/tcp_server.py
|
||||||
- Define InstrumentServer class with asyncio
|
- Define InstrumentServer class with asyncio
|
||||||
- **Commit:** "Add async TCP server foundation"
|
- **Commit:** "Add async TCP server foundation"
|
||||||
|
|
||||||
@@ -261,7 +259,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
- **Commit:** "Implement TCP client handling"
|
- **Commit:** "Implement TCP client handling"
|
||||||
|
|
||||||
### Task 8.3: Create server main entry point
|
### Task 8.3: Create server main entry point
|
||||||
- Create src/py_dvt_ate/server/main.py
|
- Create src/py_dvt_ate/simulation/server.py
|
||||||
- Wire up physics engine and instruments
|
- Wire up physics engine and instruments
|
||||||
- Add CLI command to start server
|
- Add CLI command to start server
|
||||||
- **Commit:** "Add simulation server entry point"
|
- **Commit:** "Add simulation server entry point"
|
||||||
@@ -278,13 +276,13 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Create client-side communication.
|
**Goal:** Create client-side communication.
|
||||||
|
|
||||||
### Task 9.1: Define transport protocol
|
### Task 9.1: Define transport protocol
|
||||||
- Create src/py_dvt_ate/transport/__init__.py
|
- Create src/py_dvt_ate/instruments/transport/__init__.py
|
||||||
- Create src/py_dvt_ate/transport/base.py
|
- Create src/py_dvt_ate/instruments/transport/base.py
|
||||||
- Define Transport Protocol class
|
- Define Transport Protocol class
|
||||||
- **Commit:** "Add transport protocol definition"
|
- **Commit:** "Add transport protocol definition"
|
||||||
|
|
||||||
### Task 9.2: Implement TCP transport
|
### Task 9.2: Implement TCP transport
|
||||||
- Create src/py_dvt_ate/transport/tcp.py
|
- Create src/py_dvt_ate/instruments/transport/tcp.py
|
||||||
- Implement TCPTransport class
|
- Implement TCPTransport class
|
||||||
- connect(), write(), read(), query() methods
|
- connect(), write(), read(), query() methods
|
||||||
- **Commit:** "Implement TCP transport"
|
- **Commit:** "Implement TCP transport"
|
||||||
@@ -301,19 +299,19 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Create instrument drivers using transport.
|
**Goal:** Create instrument drivers using transport.
|
||||||
|
|
||||||
### Task 10.1: Define driver base class
|
### Task 10.1: Define driver base class
|
||||||
- Create src/py_dvt_ate/drivers/__init__.py
|
- Create src/py_dvt_ate/instruments/drivers/__init__.py
|
||||||
- Create src/py_dvt_ate/drivers/base.py
|
- Create src/py_dvt_ate/instruments/drivers/base.py
|
||||||
- Define BaseDriver with transport dependency
|
- Define BaseDriver with transport dependency
|
||||||
- **Commit:** "Add driver base class"
|
- **Commit:** "Add driver base class"
|
||||||
|
|
||||||
### Task 10.2: Implement thermal chamber driver
|
### Task 10.2: Implement thermal chamber driver
|
||||||
- Create src/py_dvt_ate/drivers/thermal_chamber.py
|
- Create src/py_dvt_ate/instruments/drivers/chamber.py
|
||||||
- Methods map to SCPI commands
|
- Methods map to SCPI commands
|
||||||
- **Commit:** "Add thermal chamber driver"
|
- **Commit:** "Add thermal chamber driver"
|
||||||
|
|
||||||
### Task 10.3: Implement PSU and DMM drivers
|
### Task 10.3: Implement PSU and DMM drivers
|
||||||
- Create src/py_dvt_ate/drivers/power_supply.py
|
- Create src/py_dvt_ate/instruments/drivers/power_supply.py
|
||||||
- Create src/py_dvt_ate/drivers/multimeter.py
|
- Create src/py_dvt_ate/instruments/drivers/multimeter.py
|
||||||
- **Commit:** "Add PSU and DMM drivers"
|
- **Commit:** "Add PSU and DMM drivers"
|
||||||
|
|
||||||
### Task 10.4: Add driver tests
|
### Task 10.4: Add driver tests
|
||||||
@@ -323,32 +321,30 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sprint 11: Hardware Abstraction Layer
|
## Sprint 11: Instrument Interfaces
|
||||||
|
|
||||||
**Goal:** Create HAL interfaces and implementations.
|
**Goal:** Create instrument protocol interfaces and factory.
|
||||||
|
|
||||||
### Task 11.1: Define HAL protocols
|
### Task 11.1: Define instrument interface protocols
|
||||||
- Create src/py_dvt_ate/hal/__init__.py
|
- Create src/py_dvt_ate/instruments/interfaces.py
|
||||||
- Create src/py_dvt_ate/hal/interfaces.py
|
- Define IThermalChamber, IPowerSupply, IMultimeter protocols
|
||||||
- Define IThermalChamber, IPowerSupply, IMultimeter
|
- **Commit:** "Add instrument interface protocols"
|
||||||
- **Commit:** "Add HAL protocol definitions"
|
|
||||||
|
|
||||||
### Task 11.2: Implement HAL wrappers
|
### Task 11.2: Ensure drivers implement interfaces
|
||||||
- Create src/py_dvt_ate/hal/impl/__init__.py
|
- Update drivers to satisfy Protocol interfaces
|
||||||
- Create HAL implementation classes
|
- Add type hints for interface compliance
|
||||||
- Wrap drivers with HAL interface
|
- **Commit:** "Implement instrument interfaces in drivers"
|
||||||
- **Commit:** "Add HAL implementations"
|
|
||||||
|
|
||||||
### Task 11.3: Create instrument factory
|
### Task 11.3: Create instrument factory
|
||||||
- Create src/py_dvt_ate/hal/factory.py
|
- Create src/py_dvt_ate/instruments/factory.py
|
||||||
- InstrumentSet dataclass
|
- InstrumentSet dataclass
|
||||||
- InstrumentFactory.create() method
|
- InstrumentFactory.create() method
|
||||||
- **Commit:** "Add instrument factory"
|
- **Commit:** "Add instrument factory"
|
||||||
|
|
||||||
### Task 11.4: Add HAL tests
|
### Task 11.4: Add instrument interface tests
|
||||||
- Create tests/unit/test_hal.py
|
- Create tests/unit/test_instruments.py
|
||||||
- Test factory creates correct types
|
- Test factory creates correct types
|
||||||
- **Commit:** "Add HAL unit tests"
|
- **Commit:** "Add instrument interface tests"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -357,13 +353,12 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** YAML-based configuration.
|
**Goal:** YAML-based configuration.
|
||||||
|
|
||||||
### Task 12.1: Define config models
|
### Task 12.1: Define config models
|
||||||
- Create src/py_dvt_ate/config/__init__.py
|
- Create src/py_dvt_ate/app/config.py
|
||||||
- Create src/py_dvt_ate/config/models.py
|
- Define Pydantic models for all config sections
|
||||||
- Pydantic models for all config sections
|
|
||||||
- **Commit:** "Add configuration Pydantic models"
|
- **Commit:** "Add configuration Pydantic models"
|
||||||
|
|
||||||
### Task 12.2: Implement config loader
|
### Task 12.2: Implement config loader
|
||||||
- Create src/py_dvt_ate/config/loader.py
|
- Add load_config() function to src/py_dvt_ate/app/config.py
|
||||||
- Load YAML, validate with Pydantic
|
- Load YAML, validate with Pydantic
|
||||||
- Environment variable overrides
|
- Environment variable overrides
|
||||||
- **Commit:** "Implement configuration loader"
|
- **Commit:** "Implement configuration loader"
|
||||||
@@ -410,25 +405,25 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
**Goal:** Test execution orchestration.
|
**Goal:** Test execution orchestration.
|
||||||
|
|
||||||
### Task 14.1: Define test interface and models
|
### Task 14.1: Define test interface and models
|
||||||
- Create src/py_dvt_ate/executive/__init__.py
|
- Create src/py_dvt_ate/framework/__init__.py
|
||||||
- Create src/py_dvt_ate/executive/models.py
|
- Create src/py_dvt_ate/framework/context.py
|
||||||
- TestStatus enum, TestContext, ITest protocol
|
- TestStatus enum, TestContext, ITest protocol
|
||||||
- **Commit:** "Add test executive models"
|
- **Commit:** "Add test framework models"
|
||||||
|
|
||||||
### Task 14.2: Implement test logger
|
### Task 14.2: Implement test logger
|
||||||
- Create src/py_dvt_ate/executive/logger.py
|
- Create src/py_dvt_ate/framework/logger.py
|
||||||
- Log measurements and events
|
- Log measurements and events
|
||||||
- **Commit:** "Implement test logger"
|
- **Commit:** "Implement test logger"
|
||||||
|
|
||||||
### Task 14.3: Implement limit checker
|
### Task 14.3: Implement limit checker
|
||||||
- Create src/py_dvt_ate/executive/limits.py
|
- Create src/py_dvt_ate/framework/limits.py
|
||||||
- Evaluate pass/fail against limits
|
- Evaluate pass/fail against limits
|
||||||
- **Commit:** "Implement limit checker"
|
- **Commit:** "Implement limit checker"
|
||||||
|
|
||||||
### Task 14.4: Implement test sequencer
|
### Task 14.4: Implement test runner
|
||||||
- Create src/py_dvt_ate/executive/sequencer.py
|
- Create src/py_dvt_ate/framework/runner.py
|
||||||
- Run tests, collect results
|
- Run tests, collect results
|
||||||
- **Commit:** "Implement test sequencer"
|
- **Commit:** "Implement test runner"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -443,7 +438,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
- **Commit:** "Add DVT test base class"
|
- **Commit:** "Add DVT test base class"
|
||||||
|
|
||||||
### Task 15.2: Implement TempCo test
|
### Task 15.2: Implement TempCo test
|
||||||
- Create src/py_dvt_ate/tests/tempco.py
|
- Create src/py_dvt_ate/tests/thermal/tempco.py
|
||||||
- Temperature sweep logic
|
- Temperature sweep logic
|
||||||
- Vout measurement at each temperature
|
- Vout measurement at each temperature
|
||||||
- TempCo calculation
|
- TempCo calculation
|
||||||
@@ -517,40 +512,38 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
|
|||||||
## File Dependencies Map
|
## File Dependencies Map
|
||||||
|
|
||||||
```
|
```
|
||||||
physics/models.py → (none)
|
simulation/physics/models.py → (none)
|
||||||
physics/dut/base.py → models.py
|
simulation/physics/models/base.py → models.py
|
||||||
physics/dut/ldo.py → base.py, models.py
|
simulation/physics/models/ldo.py → base.py, models.py
|
||||||
physics/thermal.py → models.py
|
simulation/physics/thermal.py → models.py
|
||||||
physics/engine.py → models.py, thermal.py, dut/base.py
|
simulation/physics/engine.py → models.py, thermal.py, models/base.py
|
||||||
|
|
||||||
dashboard/app.py → physics/engine.py (Sprint 4, direct connection)
|
app/dashboard/app.py → simulation/physics/engine.py (Sprint 4)
|
||||||
|
|
||||||
instruments/scpi_parser.py → (none)
|
instruments/scpi.py → (none)
|
||||||
instruments/base.py → scpi_parser.py
|
simulation/virtual/base.py → instruments/scpi.py
|
||||||
instruments/*_sim.py → base.py, physics/engine.py
|
simulation/virtual/*.py → base.py, simulation/physics/engine.py
|
||||||
|
|
||||||
transport/base.py → (none)
|
instruments/transport/base.py → (none)
|
||||||
transport/tcp.py → base.py
|
instruments/transport/tcp.py → base.py
|
||||||
|
|
||||||
drivers/base.py → transport/base.py
|
instruments/drivers/base.py → instruments/transport/base.py
|
||||||
drivers/*.py → base.py
|
instruments/drivers/*.py → base.py
|
||||||
|
|
||||||
hal/interfaces.py → (none)
|
instruments/interfaces.py → (none)
|
||||||
hal/impl/*.py → interfaces.py, drivers/*.py
|
instruments/factory.py → interfaces.py, drivers/*.py
|
||||||
hal/factory.py → interfaces.py, impl/*.py
|
|
||||||
|
|
||||||
config/models.py → (none)
|
app/config.py → (none)
|
||||||
config/loader.py → models.py
|
|
||||||
|
|
||||||
data/models.py → (none)
|
data/models.py → (none)
|
||||||
data/repository.py → models.py
|
data/repository.py → models.py
|
||||||
|
|
||||||
executive/models.py → hal/interfaces.py
|
framework/context.py → instruments/interfaces.py
|
||||||
executive/*.py → models.py, data/repository.py
|
framework/*.py → context.py, data/repository.py
|
||||||
|
|
||||||
tests/*.py → executive/models.py, hal/interfaces.py
|
tests/*.py → framework/context.py, instruments/interfaces.py
|
||||||
|
|
||||||
dashboard/app.py → hal/factory.py (Sprint 17, upgraded)
|
app/dashboard/app.py → instruments/factory.py (Sprint 17, upgraded)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -578,7 +571,7 @@ MAJOR.MINOR.PATCH[-PRERELEASE]
|
|||||||
| 3 | `v0.1.0-alpha.1` | Physics engine working | Pre-release |
|
| 3 | `v0.1.0-alpha.1` | Physics engine working | Pre-release |
|
||||||
| 4 | `v0.1.0-alpha.2` | Visual demo (dashboard) | Pre-release |
|
| 4 | `v0.1.0-alpha.2` | Visual demo (dashboard) | Pre-release |
|
||||||
| 8 | `v0.1.0-alpha.3` | Network ready (TCP server) | Pre-release |
|
| 8 | `v0.1.0-alpha.3` | Network ready (TCP server) | Pre-release |
|
||||||
| 11 | `v0.1.0-beta.1` | HAL complete | Pre-release |
|
| 11 | `v0.1.0-beta.1` | Interfaces complete | Pre-release |
|
||||||
| 15 | `v0.1.0-beta.2` | First DVT test runs | Pre-release |
|
| 15 | `v0.1.0-beta.2` | First DVT test runs | Pre-release |
|
||||||
| 17 | `v0.1.0` | **MVP Complete** | Release |
|
| 17 | `v0.1.0` | **MVP Complete** | Release |
|
||||||
|
|
||||||
@@ -644,7 +637,7 @@ Maintain `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/
|
|||||||
| 4 | `v0.1.0-alpha.2` | **Visual Demo!** | Interactive Streamlit showing physics |
|
| 4 | `v0.1.0-alpha.2` | **Visual Demo!** | Interactive Streamlit showing physics |
|
||||||
| 7 | - | Instruments Done | SCPI simulators respond to commands |
|
| 7 | - | Instruments Done | SCPI simulators respond to commands |
|
||||||
| 8 | `v0.1.0-alpha.3` | Network Ready | TCP server accepts connections |
|
| 8 | `v0.1.0-alpha.3` | Network Ready | TCP server accepts connections |
|
||||||
| 11 | `v0.1.0-beta.1` | HAL Complete | Abstraction layer swappable |
|
| 11 | `v0.1.0-beta.1` | Interfaces Complete | Instrument layer swappable |
|
||||||
| 15 | `v0.1.0-beta.2` | First Test | TempCo characterisation runs |
|
| 15 | `v0.1.0-beta.2` | First Test | TempCo characterisation runs |
|
||||||
| 17 | `v0.1.0` | **MVP Complete** | Full end-to-end workflow |
|
| 17 | `v0.1.0` | **MVP Complete** | Full end-to-end workflow |
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
py-dvt-ate = "py_dvt_ate.cli.main:app"
|
py-dvt-ate = "py_dvt_ate.app.cli:app"
|
||||||
py-dvt-ate-server = "py_dvt_ate.server.main:main"
|
py-dvt-ate-server = "py_dvt_ate.simulation.server:main"
|
||||||
py-dvt-ate-dashboard = "py_dvt_ate.dashboard.app:main"
|
py-dvt-ate-dashboard = "py_dvt_ate.app.dashboard.app:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -86,5 +86,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.0.1"
|
__version__ = "0.1.0-alpha.3"
|
||||||
|
|||||||
5
src/py_dvt_ate/app/__init__.py
Normal file
5
src/py_dvt_ate/app/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Application entry points.
|
||||||
|
|
||||||
|
Contains CLI, dashboard, and configuration loading for the
|
||||||
|
py_dvt_ate application.
|
||||||
|
"""
|
||||||
87
src/py_dvt_ate/app/cli.py
Normal file
87
src/py_dvt_ate/app/cli.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Command-line interface for py_dvt_ate."""
|
||||||
|
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from py_dvt_ate import __version__
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
name="py-dvt-ate",
|
||||||
|
help="Coupled Physics DVT Simulation Platform",
|
||||||
|
add_completion=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def version_callback(value: bool) -> None:
|
||||||
|
"""Print version and exit."""
|
||||||
|
if value:
|
||||||
|
typer.echo(f"py-dvt-ate version {__version__}")
|
||||||
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback()
|
||||||
|
def main(
|
||||||
|
version: Annotated[
|
||||||
|
Optional[bool],
|
||||||
|
typer.Option(
|
||||||
|
"--version",
|
||||||
|
"-v",
|
||||||
|
help="Show version and exit.",
|
||||||
|
callback=version_callback,
|
||||||
|
is_eager=True,
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
) -> None:
|
||||||
|
"""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__":
|
||||||
|
app()
|
||||||
9
src/py_dvt_ate/app/dashboard/__init__.py
Normal file
9
src/py_dvt_ate/app/dashboard/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Streamlit dashboard for real-time monitoring.
|
||||||
|
|
||||||
|
Provides visualisation of instrument status, test progress,
|
||||||
|
and historical results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from py_dvt_ate.app.dashboard.app import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
321
src/py_dvt_ate/app/dashboard/app.py
Normal file
321
src/py_dvt_ate/app/dashboard/app.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""Streamlit dashboard application for physics simulation visualisation.
|
||||||
|
|
||||||
|
This module provides an interactive dashboard for visualising the physics
|
||||||
|
engine directly, demonstrating thermal-electrical coupling in real-time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||||
|
|
||||||
|
|
||||||
|
# History buffer size for charts
|
||||||
|
HISTORY_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimulationHistory:
|
||||||
|
"""Stores time series data for visualisation."""
|
||||||
|
|
||||||
|
time: deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_SIZE))
|
||||||
|
chamber_temp: deque[float] = field(
|
||||||
|
default_factory=lambda: deque(maxlen=HISTORY_SIZE)
|
||||||
|
)
|
||||||
|
case_temp: deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_SIZE))
|
||||||
|
junction_temp: deque[float] = field(
|
||||||
|
default_factory=lambda: deque(maxlen=HISTORY_SIZE)
|
||||||
|
)
|
||||||
|
output_voltage: deque[float] = field(
|
||||||
|
default_factory=lambda: deque(maxlen=HISTORY_SIZE)
|
||||||
|
)
|
||||||
|
power_dissipation: deque[float] = field(
|
||||||
|
default_factory=lambda: deque(maxlen=HISTORY_SIZE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_session_state() -> None:
|
||||||
|
"""Initialise Streamlit session state."""
|
||||||
|
if "engine" not in st.session_state:
|
||||||
|
st.session_state.engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
if "history" not in st.session_state:
|
||||||
|
st.session_state.history = SimulationHistory()
|
||||||
|
if "running" not in st.session_state:
|
||||||
|
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() -> None:
|
||||||
|
"""Advance the simulation based on elapsed real time and multiplier."""
|
||||||
|
engine: PhysicsEngine = st.session_state.engine
|
||||||
|
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):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
# Record current state in history
|
||||||
|
thermal = engine.get_thermal_state()
|
||||||
|
electrical = engine.get_electrical_state()
|
||||||
|
|
||||||
|
history.time.append(thermal.timestamp)
|
||||||
|
history.chamber_temp.append(thermal.chamber_temperature)
|
||||||
|
history.case_temp.append(thermal.case_temperature)
|
||||||
|
history.junction_temp.append(thermal.junction_temperature)
|
||||||
|
history.output_voltage.append(electrical.output_voltage)
|
||||||
|
history.power_dissipation.append(electrical.power_dissipation)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_engine_from_session_state() -> None:
|
||||||
|
"""Sync engine parameters from session state (called by fragment)."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def display_controls() -> None:
|
||||||
|
"""Display simulation control panel in sidebar."""
|
||||||
|
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
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@st.fragment(run_every=0.1)
|
||||||
|
def simulation_display() -> None:
|
||||||
|
"""Fragment that displays and updates simulation state."""
|
||||||
|
engine: PhysicsEngine = st.session_state.engine
|
||||||
|
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()
|
||||||
|
electrical = engine.get_electrical_state()
|
||||||
|
|
||||||
|
# Current state metrics
|
||||||
|
st.subheader("Current State")
|
||||||
|
col1, col2, col3, col4 = st.columns(4)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.metric("Chamber Temp", f"{thermal.chamber_temperature:.2f} C")
|
||||||
|
with col2:
|
||||||
|
st.metric("Case Temp", f"{thermal.case_temperature:.2f} C")
|
||||||
|
with col3:
|
||||||
|
st.metric("Junction Temp", f"{thermal.junction_temperature:.2f} C")
|
||||||
|
with col4:
|
||||||
|
st.metric("Output Voltage", f"{electrical.output_voltage:.4f} V")
|
||||||
|
|
||||||
|
col5, col6, col7, col8 = st.columns(4)
|
||||||
|
|
||||||
|
with col5:
|
||||||
|
st.metric("Input Voltage", f"{electrical.input_voltage:.2f} V")
|
||||||
|
with col6:
|
||||||
|
st.metric("Load Current", f"{electrical.load_current * 1000:.1f} mA")
|
||||||
|
with col7:
|
||||||
|
st.metric("Power Diss.", f"{electrical.power_dissipation * 1000:.2f} mW")
|
||||||
|
with col8:
|
||||||
|
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
|
||||||
|
st.subheader("Temperature History")
|
||||||
|
if len(history.time) < 2:
|
||||||
|
st.info("Start the simulation to see temperature data")
|
||||||
|
else:
|
||||||
|
chart_data = {
|
||||||
|
"Time (s)": list(history.time),
|
||||||
|
"Chamber": list(history.chamber_temp),
|
||||||
|
"Case": list(history.case_temp),
|
||||||
|
"Junction": list(history.junction_temp),
|
||||||
|
}
|
||||||
|
st.line_chart(
|
||||||
|
chart_data,
|
||||||
|
x="Time (s)",
|
||||||
|
y=["Chamber", "Case", "Junction"],
|
||||||
|
color=["#1f77b4", "#ff7f0e", "#d62728"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Self-heating demonstration
|
||||||
|
st.subheader("Self-Heating Demonstration")
|
||||||
|
|
||||||
|
delta_t_jc = thermal.junction_temperature - thermal.case_temperature
|
||||||
|
delta_t_ca = thermal.case_temperature - thermal.chamber_temperature
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
`T_junction = T_case + P_diss x theta_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")
|
||||||
|
else:
|
||||||
|
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 main() -> None:
|
||||||
|
"""Main entry point for the Streamlit dashboard."""
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="py-dvt-ate Virtual Lab Bench",
|
||||||
|
page_icon="🔬",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.title("py-dvt-ate Virtual Lab Bench")
|
||||||
|
st.markdown(
|
||||||
|
"""
|
||||||
|
Interactive physics simulation demonstrating coupled thermal-electrical
|
||||||
|
behaviour of an LDO voltage regulator.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
init_session_state()
|
||||||
|
|
||||||
|
# Sidebar controls (static - doesn't need fragment)
|
||||||
|
display_controls()
|
||||||
|
|
||||||
|
# Dynamic simulation display (uses fragment for smooth updates)
|
||||||
|
simulation_display()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Command-line interface."""
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Command-line interface for py_dvt_ate."""
|
|
||||||
|
|
||||||
from typing import Annotated, Optional
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from py_dvt_ate import __version__
|
|
||||||
|
|
||||||
app = typer.Typer(
|
|
||||||
name="py-dvt-ate",
|
|
||||||
help="Coupled Physics DVT Simulation Platform",
|
|
||||||
add_completion=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def version_callback(value: bool) -> None:
|
|
||||||
"""Print version and exit."""
|
|
||||||
if value:
|
|
||||||
typer.echo(f"py-dvt-ate version {__version__}")
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
|
||||||
def main(
|
|
||||||
version: Annotated[
|
|
||||||
Optional[bool],
|
|
||||||
typer.Option(
|
|
||||||
"--version",
|
|
||||||
"-v",
|
|
||||||
help="Show version and exit.",
|
|
||||||
callback=version_callback,
|
|
||||||
is_eager=True,
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
) -> None:
|
|
||||||
"""py-dvt-ate: Coupled Physics DVT Simulation Platform."""
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Configuration handling."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Streamlit dashboard."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Instrument SCPI drivers."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Test execution framework."""
|
|
||||||
5
src/py_dvt_ate/framework/__init__.py
Normal file
5
src/py_dvt_ate/framework/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Test execution framework.
|
||||||
|
|
||||||
|
Provides test sequencing, measurement logging, limit checking,
|
||||||
|
and runtime context management for DVT characterisation tests.
|
||||||
|
"""
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Hardware Abstraction Layer."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""HAL implementations."""
|
|
||||||
@@ -1 +1,9 @@
|
|||||||
"""Virtual instrument implementations."""
|
"""Instrument control package.
|
||||||
|
|
||||||
|
This package provides everything needed to communicate with lab instruments:
|
||||||
|
- Protocol interfaces (IThermalChamber, IPowerSupply, IMultimeter)
|
||||||
|
- SCPI command parsing
|
||||||
|
- Transport layer (TCP, VISA)
|
||||||
|
- Instrument drivers
|
||||||
|
- Factory for creating configured instrument sets
|
||||||
|
"""
|
||||||
|
|||||||
5
src/py_dvt_ate/instruments/drivers/__init__.py
Normal file
5
src/py_dvt_ate/instruments/drivers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""SCPI driver implementations for lab instruments.
|
||||||
|
|
||||||
|
Each driver translates high-level operations into SCPI commands
|
||||||
|
and handles responses from instruments.
|
||||||
|
"""
|
||||||
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,
|
||||||
|
)
|
||||||
6
src/py_dvt_ate/instruments/transport/__init__.py
Normal file
6
src/py_dvt_ate/instruments/transport/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Transport layer for instrument communication.
|
||||||
|
|
||||||
|
Provides connection abstractions for different backends:
|
||||||
|
- TCP sockets (for simulation server)
|
||||||
|
- PyVISA (for real instruments)
|
||||||
|
"""
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Physics simulation engine."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Device Under Test models."""
|
|
||||||
5
src/py_dvt_ate/reporting/__init__.py
Normal file
5
src/py_dvt_ate/reporting/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Report generation.
|
||||||
|
|
||||||
|
Generates test reports from stored data in various formats
|
||||||
|
including PDF and HTML.
|
||||||
|
"""
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Simulation server."""
|
|
||||||
10
src/py_dvt_ate/simulation/__init__.py
Normal file
10
src/py_dvt_ate/simulation/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Physics simulation package.
|
||||||
|
|
||||||
|
Provides virtual instruments backed by a coupled thermal-electrical
|
||||||
|
physics engine. Used for development and testing without real hardware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
||||||
|
from py_dvt_ate.simulation.tcp_server import InstrumentServer
|
||||||
|
|
||||||
|
__all__ = ["InstrumentServer", "ServerConfig", "SimulationServer"]
|
||||||
5
src/py_dvt_ate/simulation/physics/__init__.py
Normal file
5
src/py_dvt_ate/simulation/physics/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Physics engine for thermal-electrical simulation.
|
||||||
|
|
||||||
|
Implements coupled thermal and electrical domain models with
|
||||||
|
realistic time constants and temperature-dependent behaviour.
|
||||||
|
"""
|
||||||
222
src/py_dvt_ate/simulation/physics/engine.py
Normal file
222
src/py_dvt_ate/simulation/physics/engine.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""Physics engine for coupled thermal-electrical simulation.
|
||||||
|
|
||||||
|
The physics engine maintains the simulation state and advances it
|
||||||
|
in discrete time steps, modelling the thermal and electrical coupling
|
||||||
|
between the DUT and its environment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.physics.state import ElectricalState, ThermalState
|
||||||
|
from py_dvt_ate.simulation.physics.thermal import (
|
||||||
|
calculate_junction_temperature,
|
||||||
|
update_case_temperature,
|
||||||
|
update_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from py_dvt_ate.simulation.physics.models.base import DUTModel
|
||||||
|
|
||||||
|
|
||||||
|
class PhysicsEngine:
|
||||||
|
"""Coupled thermal-electrical physics simulation.
|
||||||
|
|
||||||
|
Runs at a fixed timestep, updating thermal and electrical state
|
||||||
|
based on the DUT model and environmental conditions.
|
||||||
|
|
||||||
|
The simulation models:
|
||||||
|
- Chamber temperature approaching setpoint (first-order response)
|
||||||
|
- Case temperature driven by chamber and self-heating
|
||||||
|
- Junction temperature from case temperature and thermal resistance
|
||||||
|
- Electrical behaviour from the DUT model (temperature-dependent)
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
dt: Simulation timestep in seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
update_rate_hz: float = 100.0,
|
||||||
|
dut_model: DUTModel | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialise the physics engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
update_rate_hz: Simulation update rate in Hz. Defaults to 100.
|
||||||
|
dut_model: DUT model to use for electrical calculations.
|
||||||
|
If None, a default LDO model will be used.
|
||||||
|
"""
|
||||||
|
self.dt = 1.0 / update_rate_hz
|
||||||
|
|
||||||
|
# Lazily import to avoid circular dependencies
|
||||||
|
if dut_model is None:
|
||||||
|
from py_dvt_ate.simulation.physics.models.ldo import LDOModel
|
||||||
|
|
||||||
|
self._dut: DUTModel = LDOModel()
|
||||||
|
else:
|
||||||
|
self._dut = dut_model
|
||||||
|
|
||||||
|
# Thermal parameters
|
||||||
|
self._tau_chamber = 30.0 # seconds
|
||||||
|
self._tau_case = 5.0 # seconds
|
||||||
|
self._theta_jc = 15.0 # degC/W
|
||||||
|
self._theta_ca = 5.0 # degC/W
|
||||||
|
|
||||||
|
# State variables
|
||||||
|
self._t_setpoint = 25.0
|
||||||
|
self._t_chamber = 25.0
|
||||||
|
self._t_case = 25.0
|
||||||
|
self._v_in = 0.0
|
||||||
|
self._i_load = 0.0
|
||||||
|
self._output_enabled = False
|
||||||
|
self._sim_time = 0.0
|
||||||
|
|
||||||
|
def step(self) -> None:
|
||||||
|
"""Advance simulation by one timestep.
|
||||||
|
|
||||||
|
Updates thermal and electrical state based on current conditions.
|
||||||
|
The thermal-electrical coupling works as follows:
|
||||||
|
1. Calculate current power dissipation from DUT model
|
||||||
|
2. Update chamber temperature towards setpoint
|
||||||
|
3. Update case temperature including self-heating
|
||||||
|
4. Advance simulation time
|
||||||
|
"""
|
||||||
|
# Calculate power dissipation (uses current junction temperature estimate)
|
||||||
|
p_diss = self._calculate_power_dissipation()
|
||||||
|
|
||||||
|
# Update chamber temperature (first-order response to setpoint)
|
||||||
|
self._t_chamber = update_temperature(
|
||||||
|
current_temperature=self._t_chamber,
|
||||||
|
target_temperature=self._t_setpoint,
|
||||||
|
time_constant=self._tau_chamber,
|
||||||
|
dt=self.dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update case temperature (driven by chamber + self-heating)
|
||||||
|
self._t_case = update_case_temperature(
|
||||||
|
case_temperature=self._t_case,
|
||||||
|
ambient_temperature=self._t_chamber,
|
||||||
|
power_dissipation=p_diss,
|
||||||
|
time_constant=self._tau_case,
|
||||||
|
theta_ca=self._theta_ca,
|
||||||
|
dt=self.dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Advance simulation time
|
||||||
|
self._sim_time += self.dt
|
||||||
|
|
||||||
|
def get_thermal_state(self) -> ThermalState:
|
||||||
|
"""Get current thermal state snapshot.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Immutable ThermalState with current temperatures.
|
||||||
|
"""
|
||||||
|
p_diss = self._calculate_power_dissipation()
|
||||||
|
t_junction = calculate_junction_temperature(
|
||||||
|
case_temperature=self._t_case,
|
||||||
|
power_dissipation=p_diss,
|
||||||
|
theta_jc=self._theta_jc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ThermalState(
|
||||||
|
chamber_temperature=self._t_chamber,
|
||||||
|
case_temperature=self._t_case,
|
||||||
|
junction_temperature=t_junction,
|
||||||
|
timestamp=self._sim_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_electrical_state(self) -> ElectricalState:
|
||||||
|
"""Get current electrical state snapshot.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Immutable ElectricalState with current electrical values.
|
||||||
|
"""
|
||||||
|
p_diss = self._calculate_power_dissipation()
|
||||||
|
t_junction = calculate_junction_temperature(
|
||||||
|
case_temperature=self._t_case,
|
||||||
|
power_dissipation=p_diss,
|
||||||
|
theta_jc=self._theta_jc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._output_enabled:
|
||||||
|
v_out = self._dut.calculate_output_voltage(t_junction)
|
||||||
|
i_q = self._dut.calculate_quiescent_current(t_junction)
|
||||||
|
i_load = self._i_load
|
||||||
|
else:
|
||||||
|
v_out = 0.0
|
||||||
|
i_q = 0.0
|
||||||
|
i_load = 0.0
|
||||||
|
|
||||||
|
return ElectricalState(
|
||||||
|
input_voltage=self._v_in,
|
||||||
|
output_voltage=v_out,
|
||||||
|
load_current=i_load,
|
||||||
|
quiescent_current=i_q,
|
||||||
|
power_dissipation=p_diss,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_power_dissipation(self) -> float:
|
||||||
|
"""Calculate current power dissipation.
|
||||||
|
|
||||||
|
Uses the current case temperature as an approximation for junction
|
||||||
|
temperature in the power calculation. The true junction temperature
|
||||||
|
depends on power dissipation, creating a feedback loop that is
|
||||||
|
resolved iteratively through the simulation steps.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Power dissipation in watts.
|
||||||
|
"""
|
||||||
|
if not self._output_enabled:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Use case temperature as junction estimate for power calculation
|
||||||
|
# This avoids circular dependency in the calculation
|
||||||
|
return self._dut.calculate_power_dissipation(
|
||||||
|
input_voltage=self._v_in,
|
||||||
|
load_current=self._i_load,
|
||||||
|
junction_temperature=self._t_case,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_chamber_setpoint(self, temperature: float) -> None:
|
||||||
|
"""Set chamber target temperature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
temperature: Target temperature in degrees Celsius.
|
||||||
|
"""
|
||||||
|
self._t_setpoint = temperature
|
||||||
|
|
||||||
|
def set_input_voltage(self, voltage: float) -> None:
|
||||||
|
"""Set DUT input voltage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voltage: Input voltage in volts.
|
||||||
|
"""
|
||||||
|
self._v_in = voltage
|
||||||
|
|
||||||
|
def set_load_current(self, current: float) -> None:
|
||||||
|
"""Set DUT load current.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Load current in amps.
|
||||||
|
"""
|
||||||
|
self._i_load = current
|
||||||
|
|
||||||
|
def set_output_enabled(self, enabled: bool) -> None:
|
||||||
|
"""Enable or disable DUT power.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: True to enable output, False to disable.
|
||||||
|
"""
|
||||||
|
self._output_enabled = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simulation_time(self) -> float:
|
||||||
|
"""Get current simulation time in seconds."""
|
||||||
|
return self._sim_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_output_enabled(self) -> bool:
|
||||||
|
"""Check if DUT output is enabled."""
|
||||||
|
return self._output_enabled
|
||||||
10
src/py_dvt_ate/simulation/physics/models/__init__.py
Normal file
10
src/py_dvt_ate/simulation/physics/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Device Under Test (DUT) models.
|
||||||
|
|
||||||
|
Provides thermal and electrical models for various device types
|
||||||
|
including LDO regulators, op-amps, and other components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.physics.models.base import DUTModel
|
||||||
|
from py_dvt_ate.simulation.physics.models.ldo import LDOModel, LDOParameters
|
||||||
|
|
||||||
|
__all__ = ["DUTModel", "LDOModel", "LDOParameters"]
|
||||||
61
src/py_dvt_ate/simulation/physics/models/base.py
Normal file
61
src/py_dvt_ate/simulation/physics/models/base.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Base protocol for Device Under Test (DUT) models.
|
||||||
|
|
||||||
|
Defines the interface that all DUT models must implement to integrate
|
||||||
|
with the physics engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DUTModel(Protocol):
|
||||||
|
"""Protocol for DUT electrical/thermal models.
|
||||||
|
|
||||||
|
DUT models encapsulate the temperature-dependent electrical behaviour
|
||||||
|
of a device, enabling realistic simulation of thermal-electrical coupling.
|
||||||
|
|
||||||
|
All temperature parameters are in degrees Celsius.
|
||||||
|
All voltage parameters are in volts.
|
||||||
|
All current parameters are in amps.
|
||||||
|
All power parameters are in watts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def calculate_output_voltage(self, junction_temperature: float) -> float:
|
||||||
|
"""Calculate the output voltage at the given junction temperature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
junction_temperature: DUT junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Output voltage in volts.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def calculate_quiescent_current(self, junction_temperature: float) -> float:
|
||||||
|
"""Calculate the quiescent current at the given junction temperature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
junction_temperature: DUT junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quiescent current in amps.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def calculate_power_dissipation(
|
||||||
|
self,
|
||||||
|
input_voltage: float,
|
||||||
|
load_current: float,
|
||||||
|
junction_temperature: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate the power dissipation for given operating conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_voltage: Input voltage in volts.
|
||||||
|
load_current: Load current in amps.
|
||||||
|
junction_temperature: DUT junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Power dissipation in watts.
|
||||||
|
"""
|
||||||
|
...
|
||||||
219
src/py_dvt_ate/simulation/physics/models/ldo.py
Normal file
219
src/py_dvt_ate/simulation/physics/models/ldo.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""LDO (Low Dropout Regulator) DUT model.
|
||||||
|
|
||||||
|
Implements temperature-dependent electrical behaviour for an LDO voltage
|
||||||
|
regulator, including output voltage tempco, quiescent current variation,
|
||||||
|
and power dissipation calculations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LDOParameters:
|
||||||
|
"""Configuration parameters for an LDO model.
|
||||||
|
|
||||||
|
All temperature coefficients are referenced to 25°C.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
nominal_output_voltage: Nominal output voltage at 25°C in volts.
|
||||||
|
tempco_ppm_per_c: Output voltage temperature coefficient in ppm/°C.
|
||||||
|
quiescent_current_a: Quiescent current at 25°C in amps.
|
||||||
|
quiescent_current_tempco: Quiescent current temperature coefficient (1/°C).
|
||||||
|
dropout_voltage: Dropout voltage at 25°C in volts.
|
||||||
|
max_output_current: Maximum output current in amps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
nominal_output_voltage: float = 3.3
|
||||||
|
tempco_ppm_per_c: float = 50.0
|
||||||
|
quiescent_current_a: float = 50e-6 # 50 µA
|
||||||
|
quiescent_current_tempco: float = 0.003 # 0.3%/°C
|
||||||
|
dropout_voltage: float = 0.3
|
||||||
|
max_output_current: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# Reference temperature for all calculations
|
||||||
|
REFERENCE_TEMPERATURE_C = 25.0
|
||||||
|
|
||||||
|
|
||||||
|
class LDOModel:
|
||||||
|
"""Temperature-dependent LDO voltage regulator model.
|
||||||
|
|
||||||
|
Models the electrical behaviour of a linear voltage regulator with:
|
||||||
|
- Output voltage that varies with temperature (tempco in ppm/°C)
|
||||||
|
- Quiescent current that varies with temperature
|
||||||
|
- Dropout voltage that increases with temperature
|
||||||
|
- Power dissipation from (Vin - Vout) × Iload + Vin × Iq
|
||||||
|
|
||||||
|
This class implements the DUTModel protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
params: LDOParameters | None = None,
|
||||||
|
input_voltage: float = 5.0,
|
||||||
|
load_current: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
"""Initialise the LDO model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: LDO parameters. Uses defaults if None.
|
||||||
|
input_voltage: Initial input voltage in volts.
|
||||||
|
load_current: Initial load current in amps.
|
||||||
|
"""
|
||||||
|
self._params = params or LDOParameters()
|
||||||
|
self._input_voltage = input_voltage
|
||||||
|
self._load_current = load_current
|
||||||
|
|
||||||
|
@property
|
||||||
|
def params(self) -> LDOParameters:
|
||||||
|
"""Get the LDO parameters."""
|
||||||
|
return self._params
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_voltage(self) -> float:
|
||||||
|
"""Get the current input voltage."""
|
||||||
|
return self._input_voltage
|
||||||
|
|
||||||
|
@input_voltage.setter
|
||||||
|
def input_voltage(self, value: float) -> None:
|
||||||
|
"""Set the input voltage."""
|
||||||
|
self._input_voltage = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def load_current(self) -> float:
|
||||||
|
"""Get the current load current."""
|
||||||
|
return self._load_current
|
||||||
|
|
||||||
|
@load_current.setter
|
||||||
|
def load_current(self, value: float) -> None:
|
||||||
|
"""Set the load current."""
|
||||||
|
self._load_current = value
|
||||||
|
|
||||||
|
def calculate_output_voltage(self, junction_temperature: float) -> float:
|
||||||
|
"""Calculate the output voltage at the given junction temperature.
|
||||||
|
|
||||||
|
Implements: V_out(T) = V_nom × (1 + TC_vout × (T - 25) × 1e-6)
|
||||||
|
|
||||||
|
The output voltage is clamped to not exceed (Vin - Vdropout) when
|
||||||
|
the regulator is in dropout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
junction_temperature: DUT junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Output voltage in volts.
|
||||||
|
"""
|
||||||
|
delta_t = junction_temperature - REFERENCE_TEMPERATURE_C
|
||||||
|
tempco_factor = 1.0 + self._params.tempco_ppm_per_c * delta_t * 1e-6
|
||||||
|
|
||||||
|
ideal_vout = self._params.nominal_output_voltage * tempco_factor
|
||||||
|
|
||||||
|
# Calculate dropout voltage at temperature
|
||||||
|
v_dropout = self._calculate_dropout_voltage(junction_temperature)
|
||||||
|
|
||||||
|
# Clamp output to not exceed (Vin - Vdropout)
|
||||||
|
max_vout = max(0.0, self._input_voltage - v_dropout)
|
||||||
|
|
||||||
|
return min(ideal_vout, max_vout)
|
||||||
|
|
||||||
|
def calculate_quiescent_current(self, junction_temperature: float) -> float:
|
||||||
|
"""Calculate the quiescent current at the given junction temperature.
|
||||||
|
|
||||||
|
Implements: I_q(T) = I_q_25 × (1 + TC_iq × (T - 25))
|
||||||
|
|
||||||
|
Args:
|
||||||
|
junction_temperature: DUT junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quiescent current in amps.
|
||||||
|
"""
|
||||||
|
delta_t = junction_temperature - REFERENCE_TEMPERATURE_C
|
||||||
|
tempco_factor = 1.0 + self._params.quiescent_current_tempco * delta_t
|
||||||
|
|
||||||
|
return self._params.quiescent_current_a * tempco_factor
|
||||||
|
|
||||||
|
def calculate_power_dissipation(
|
||||||
|
self,
|
||||||
|
input_voltage: float,
|
||||||
|
load_current: float,
|
||||||
|
junction_temperature: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate the power dissipation for given operating conditions.
|
||||||
|
|
||||||
|
Implements: P_diss = (V_in - V_out) × I_load + V_in × I_q
|
||||||
|
|
||||||
|
The power dissipation comes from:
|
||||||
|
- Voltage drop across the pass element times load current
|
||||||
|
- Quiescent current times input voltage
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_voltage: Input voltage in volts.
|
||||||
|
load_current: Load current in amps.
|
||||||
|
junction_temperature: DUT junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Power dissipation in watts.
|
||||||
|
"""
|
||||||
|
# Temporarily set input voltage for output voltage calculation
|
||||||
|
original_vin = self._input_voltage
|
||||||
|
self._input_voltage = input_voltage
|
||||||
|
|
||||||
|
try:
|
||||||
|
v_out = self.calculate_output_voltage(junction_temperature)
|
||||||
|
i_q = self.calculate_quiescent_current(junction_temperature)
|
||||||
|
|
||||||
|
# Power in pass element
|
||||||
|
p_pass = (input_voltage - v_out) * load_current
|
||||||
|
|
||||||
|
# Power from quiescent current
|
||||||
|
p_quiescent = input_voltage * i_q
|
||||||
|
|
||||||
|
return p_pass + p_quiescent
|
||||||
|
finally:
|
||||||
|
self._input_voltage = original_vin
|
||||||
|
|
||||||
|
def _calculate_dropout_voltage(self, junction_temperature: float) -> float:
|
||||||
|
"""Calculate the dropout voltage at the given temperature.
|
||||||
|
|
||||||
|
Implements: V_do(T) = V_do_25 × (T_K / 300)^1.5
|
||||||
|
|
||||||
|
where T_K is the temperature in Kelvin.
|
||||||
|
|
||||||
|
Dropout voltage increases with temperature due to increased
|
||||||
|
resistance of the pass element.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
junction_temperature: Junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dropout voltage in volts.
|
||||||
|
"""
|
||||||
|
# Convert to Kelvin
|
||||||
|
t_kelvin = junction_temperature + 273.15
|
||||||
|
|
||||||
|
# Temperature ratio (reference is approximately 300K ≈ 27°C)
|
||||||
|
temp_ratio = t_kelvin / 300.0
|
||||||
|
|
||||||
|
return self._params.dropout_voltage * (temp_ratio**1.5)
|
||||||
|
|
||||||
|
def is_in_dropout(self, junction_temperature: float) -> bool:
|
||||||
|
"""Check if the LDO is in dropout at current operating point.
|
||||||
|
|
||||||
|
The LDO is in dropout when the input voltage minus dropout voltage
|
||||||
|
is less than the nominal output voltage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
junction_temperature: Junction temperature in degrees Celsius.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if in dropout, False otherwise.
|
||||||
|
"""
|
||||||
|
v_dropout = self._calculate_dropout_voltage(junction_temperature)
|
||||||
|
headroom = self._input_voltage - v_dropout
|
||||||
|
|
||||||
|
# Get the ideal (temperature-adjusted) output voltage
|
||||||
|
delta_t = junction_temperature - REFERENCE_TEMPERATURE_C
|
||||||
|
tempco_factor = 1.0 + self._params.tempco_ppm_per_c * delta_t * 1e-6
|
||||||
|
ideal_vout = self._params.nominal_output_voltage * tempco_factor
|
||||||
|
|
||||||
|
return headroom < ideal_vout
|
||||||
48
src/py_dvt_ate/simulation/physics/state.py
Normal file
48
src/py_dvt_ate/simulation/physics/state.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Physics state dataclasses for thermal-electrical simulation.
|
||||||
|
|
||||||
|
These immutable state snapshots represent the simulation state at a point in time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ThermalState:
|
||||||
|
"""Immutable thermal state snapshot.
|
||||||
|
|
||||||
|
Represents the thermal conditions of the DUT and its environment
|
||||||
|
at a specific point in simulation time.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
chamber_temperature: Chamber air temperature in degrees Celsius.
|
||||||
|
case_temperature: DUT case/package temperature in degrees Celsius.
|
||||||
|
junction_temperature: DUT junction/die temperature in degrees Celsius.
|
||||||
|
timestamp: Simulation time in seconds since start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
chamber_temperature: float
|
||||||
|
case_temperature: float
|
||||||
|
junction_temperature: float
|
||||||
|
timestamp: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ElectricalState:
|
||||||
|
"""Immutable electrical state snapshot.
|
||||||
|
|
||||||
|
Represents the electrical conditions of the DUT at a specific
|
||||||
|
point in simulation time.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
input_voltage: DUT input voltage in volts.
|
||||||
|
output_voltage: DUT output voltage in volts.
|
||||||
|
load_current: Load current drawn from DUT in amps.
|
||||||
|
quiescent_current: DUT quiescent/bias current in amps.
|
||||||
|
power_dissipation: Total power dissipated by DUT in watts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
input_voltage: float
|
||||||
|
output_voltage: float
|
||||||
|
load_current: float
|
||||||
|
quiescent_current: float
|
||||||
|
power_dissipation: float
|
||||||
193
src/py_dvt_ate/simulation/physics/thermal.py
Normal file
193
src/py_dvt_ate/simulation/physics/thermal.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Thermal calculation functions for physics simulation.
|
||||||
|
|
||||||
|
Pure functions implementing first-order thermal response calculations
|
||||||
|
for the coupled thermal-electrical simulation.
|
||||||
|
|
||||||
|
All temperatures are in degrees Celsius.
|
||||||
|
All time values are in seconds.
|
||||||
|
All power values are in watts.
|
||||||
|
All thermal resistances are in degrees Celsius per watt (°C/W).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_temperature_derivative(
|
||||||
|
current_temperature: float,
|
||||||
|
target_temperature: float,
|
||||||
|
time_constant: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate the rate of temperature change for first-order response.
|
||||||
|
|
||||||
|
Implements: dT/dt = (T_target - T_current) / τ
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_temperature: Current temperature in degrees Celsius.
|
||||||
|
target_temperature: Target temperature in degrees Celsius.
|
||||||
|
time_constant: Thermal time constant in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rate of temperature change in degrees Celsius per second.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If time_constant is not positive.
|
||||||
|
"""
|
||||||
|
if time_constant <= 0:
|
||||||
|
msg = f"Time constant must be positive, got {time_constant}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
return (target_temperature - current_temperature) / time_constant
|
||||||
|
|
||||||
|
|
||||||
|
def update_temperature(
|
||||||
|
current_temperature: float,
|
||||||
|
target_temperature: float,
|
||||||
|
time_constant: float,
|
||||||
|
dt: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate new temperature after a time step using Euler integration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_temperature: Current temperature in degrees Celsius.
|
||||||
|
target_temperature: Target temperature in degrees Celsius.
|
||||||
|
time_constant: Thermal time constant in seconds.
|
||||||
|
dt: Time step in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New temperature in degrees Celsius after the time step.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If time_constant or dt is not positive.
|
||||||
|
"""
|
||||||
|
if dt <= 0:
|
||||||
|
msg = f"Time step must be positive, got {dt}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
derivative = calculate_temperature_derivative(
|
||||||
|
current_temperature, target_temperature, time_constant
|
||||||
|
)
|
||||||
|
return current_temperature + derivative * dt
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_case_temperature_derivative(
|
||||||
|
case_temperature: float,
|
||||||
|
ambient_temperature: float,
|
||||||
|
power_dissipation: float,
|
||||||
|
time_constant: float,
|
||||||
|
theta_ca: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate rate of case temperature change including self-heating.
|
||||||
|
|
||||||
|
Implements: dT_case/dt = (T_ambient - T_case + P_diss × θ_ca) / τ_case
|
||||||
|
|
||||||
|
The case temperature is driven by:
|
||||||
|
- Convection/conduction to ambient (chamber) temperature
|
||||||
|
- Self-heating from power dissipation through case-to-ambient thermal resistance
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_temperature: Current case temperature in degrees Celsius.
|
||||||
|
ambient_temperature: Ambient (chamber) temperature in degrees Celsius.
|
||||||
|
power_dissipation: Power dissipated by the device in watts.
|
||||||
|
time_constant: Case thermal time constant in seconds.
|
||||||
|
theta_ca: Thermal resistance from case to ambient in °C/W.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rate of case temperature change in degrees Celsius per second.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If time_constant is not positive.
|
||||||
|
"""
|
||||||
|
if time_constant <= 0:
|
||||||
|
msg = f"Time constant must be positive, got {time_constant}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
# The effective target includes self-heating contribution
|
||||||
|
thermal_drive = ambient_temperature - case_temperature + power_dissipation * theta_ca
|
||||||
|
return thermal_drive / time_constant
|
||||||
|
|
||||||
|
|
||||||
|
def update_case_temperature(
|
||||||
|
case_temperature: float,
|
||||||
|
ambient_temperature: float,
|
||||||
|
power_dissipation: float,
|
||||||
|
time_constant: float,
|
||||||
|
theta_ca: float,
|
||||||
|
dt: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate new case temperature after a time step.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_temperature: Current case temperature in degrees Celsius.
|
||||||
|
ambient_temperature: Ambient (chamber) temperature in degrees Celsius.
|
||||||
|
power_dissipation: Power dissipated by the device in watts.
|
||||||
|
time_constant: Case thermal time constant in seconds.
|
||||||
|
theta_ca: Thermal resistance from case to ambient in °C/W.
|
||||||
|
dt: Time step in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New case temperature in degrees Celsius after the time step.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If time_constant or dt is not positive.
|
||||||
|
"""
|
||||||
|
if dt <= 0:
|
||||||
|
msg = f"Time step must be positive, got {dt}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
derivative = calculate_case_temperature_derivative(
|
||||||
|
case_temperature,
|
||||||
|
ambient_temperature,
|
||||||
|
power_dissipation,
|
||||||
|
time_constant,
|
||||||
|
theta_ca,
|
||||||
|
)
|
||||||
|
return case_temperature + derivative * dt
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_junction_temperature(
|
||||||
|
case_temperature: float,
|
||||||
|
power_dissipation: float,
|
||||||
|
theta_jc: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate junction temperature from case temperature and power.
|
||||||
|
|
||||||
|
The junction temperature is assumed to respond instantaneously to
|
||||||
|
changes in case temperature and power dissipation (no thermal mass
|
||||||
|
at the die level for this simplified model).
|
||||||
|
|
||||||
|
Implements: T_junction = T_case + P_diss × θ_jc
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_temperature: Case temperature in degrees Celsius.
|
||||||
|
power_dissipation: Power dissipated by the device in watts.
|
||||||
|
theta_jc: Thermal resistance from junction to case in °C/W.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Junction temperature in degrees Celsius.
|
||||||
|
"""
|
||||||
|
return case_temperature + power_dissipation * theta_jc
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_steady_state_junction_temperature(
|
||||||
|
ambient_temperature: float,
|
||||||
|
power_dissipation: float,
|
||||||
|
theta_jc: float,
|
||||||
|
theta_ca: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate the steady-state junction temperature.
|
||||||
|
|
||||||
|
At steady state, the case temperature reaches equilibrium where
|
||||||
|
the heat flow through θ_ca equals the power dissipation.
|
||||||
|
|
||||||
|
T_case_ss = T_ambient + P_diss × θ_ca
|
||||||
|
T_junction_ss = T_case_ss + P_diss × θ_jc
|
||||||
|
= T_ambient + P_diss × (θ_jc + θ_ca)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ambient_temperature: Ambient (chamber) temperature in degrees Celsius.
|
||||||
|
power_dissipation: Power dissipated by the device in watts.
|
||||||
|
theta_jc: Thermal resistance from junction to case in °C/W.
|
||||||
|
theta_ca: Thermal resistance from case to ambient in °C/W.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Steady-state junction temperature in degrees Celsius.
|
||||||
|
"""
|
||||||
|
return ambient_temperature + power_dissipation * (theta_jc + theta_ca)
|
||||||
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.simulation.physics.engine import PhysicsEngine
|
||||||
|
from py_dvt_ate.simulation.tcp_server import InstrumentServer
|
||||||
|
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()
|
||||||
212
src/py_dvt_ate/simulation/tcp_server.py
Normal file
212
src/py_dvt_ate/simulation/tcp_server.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Async TCP server for exposing virtual instruments over network.
|
||||||
|
|
||||||
|
This module provides the InstrumentServer class that hosts virtual SCPI
|
||||||
|
instruments over TCP, allowing client applications to communicate using
|
||||||
|
standard SCPI commands over a network connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from py_dvt_ate.simulation.virtual.base import BaseInstrument
|
||||||
|
|
||||||
|
# Re-export for type checking - actual import happens at runtime via registration
|
||||||
|
__all__ = ["InstrumentServer"]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InstrumentServer:
|
||||||
|
"""Async TCP server hosting virtual 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.
|
||||||
|
|
||||||
|
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, BaseInstrument] = {}
|
||||||
|
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: BaseInstrument) -> None:
|
||||||
|
"""Register an instrument to be served on a specific port.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: TCP port number to serve the instrument on.
|
||||||
|
instrument: Virtual instrument to serve.
|
||||||
|
|
||||||
|
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) -> BaseInstrument | 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():
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
lambda r, w, inst=instrument, p=port: self._handle_client(r, w, inst, p),
|
||||||
|
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: BaseInstrument,
|
||||||
|
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("utf-8"))
|
||||||
|
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)
|
||||||
5
src/py_dvt_ate/simulation/virtual/__init__.py
Normal file
5
src/py_dvt_ate/simulation/virtual/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Virtual instrument implementations.
|
||||||
|
|
||||||
|
SCPI-compliant virtual instruments that respond like real hardware
|
||||||
|
but are backed by the physics simulation engine.
|
||||||
|
"""
|
||||||
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:
|
||||||
|
raise ValueError(f"Invalid temperature value: {command.arguments[0]}")
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ValueError(f"Invalid voltage value: {command.arguments[0]}")
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ValueError(f"Invalid current value: {command.arguments[0]}")
|
||||||
|
|
||||||
|
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}"
|
||||||
@@ -1 +1,6 @@
|
|||||||
"""DVT test implementations."""
|
"""DVT test implementations.
|
||||||
|
|
||||||
|
Contains characterisation test suites organised by category:
|
||||||
|
- thermal/ - Temperature-related tests (TempCo, etc.)
|
||||||
|
- electrical/ - Electrical tests (load regulation, etc.)
|
||||||
|
"""
|
||||||
|
|||||||
5
src/py_dvt_ate/tests/electrical/__init__.py
Normal file
5
src/py_dvt_ate/tests/electrical/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Electrical characterisation tests.
|
||||||
|
|
||||||
|
Tests for electrical performance including load regulation,
|
||||||
|
line regulation, and output accuracy.
|
||||||
|
"""
|
||||||
5
src/py_dvt_ate/tests/thermal/__init__.py
Normal file
5
src/py_dvt_ate/tests/thermal/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Thermal characterisation tests.
|
||||||
|
|
||||||
|
Tests related to temperature behaviour including temperature
|
||||||
|
coefficient (TempCo) measurements and thermal stability.
|
||||||
|
"""
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Communication layer."""
|
|
||||||
@@ -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."""
|
||||||
274
tests/integration/test_tcp_server.py
Normal file
274
tests/integration/test_tcp_server.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""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.simulation.physics.engine import PhysicsEngine
|
||||||
|
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
|
||||||
|
from py_dvt_ate.simulation.tcp_server import InstrumentServer
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
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
|
||||||
300
tests/unit/test_physics_engine.py
Normal file
300
tests/unit/test_physics_engine.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""Integration tests for the physics engine with thermal-electrical coupling.
|
||||||
|
|
||||||
|
Tests the complete physics simulation including:
|
||||||
|
- Thermal settling behaviour (chamber, case, junction)
|
||||||
|
- Self-heating effects from power dissipation
|
||||||
|
- Temperature-dependent electrical behaviour
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||||
|
from py_dvt_ate.simulation.physics.models.ldo import LDOModel, LDOParameters
|
||||||
|
|
||||||
|
|
||||||
|
class TestThermalSettling:
|
||||||
|
"""Tests for thermal settling behaviour."""
|
||||||
|
|
||||||
|
def test_chamber_approaches_setpoint(self) -> None:
|
||||||
|
"""Test chamber temperature approaches setpoint over time."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Set a new setpoint
|
||||||
|
engine.set_chamber_setpoint(85.0)
|
||||||
|
|
||||||
|
# Initial state
|
||||||
|
initial_state = engine.get_thermal_state()
|
||||||
|
assert initial_state.chamber_temperature == pytest.approx(25.0)
|
||||||
|
|
||||||
|
# Simulate for 1 second (100 steps at 100Hz)
|
||||||
|
for _ in range(100):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
state_1s = engine.get_thermal_state()
|
||||||
|
|
||||||
|
# Chamber should have moved towards 85°C but not reached it yet
|
||||||
|
# With tau=30s, after 1s: T = 25 + (85-25)*(1 - e^(-1/30)) ≈ 27.0°C
|
||||||
|
assert state_1s.chamber_temperature > 25.0
|
||||||
|
assert state_1s.chamber_temperature < 85.0
|
||||||
|
|
||||||
|
# Simulate for another 89 seconds (total 90s = 3*tau)
|
||||||
|
for _ in range(8900):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
state_90s = engine.get_thermal_state()
|
||||||
|
|
||||||
|
# After 3 time constants, should be ~95% of the way there
|
||||||
|
# T = 25 + (85-25)*(1 - e^(-3)) ≈ 82.0°C
|
||||||
|
assert state_90s.chamber_temperature > 80.0
|
||||||
|
assert state_90s.chamber_temperature < 85.0
|
||||||
|
|
||||||
|
def test_case_follows_chamber(self) -> None:
|
||||||
|
"""Test case temperature follows chamber temperature."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Set chamber to high temperature (no power, so no self-heating)
|
||||||
|
engine.set_chamber_setpoint(85.0)
|
||||||
|
|
||||||
|
# Simulate for 200 seconds (well past both time constants)
|
||||||
|
for _ in range(20000):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
state = engine.get_thermal_state()
|
||||||
|
|
||||||
|
# With no power, case should approach chamber temperature
|
||||||
|
assert state.case_temperature == pytest.approx(
|
||||||
|
state.chamber_temperature, abs=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_junction_equals_case_with_no_power(self) -> None:
|
||||||
|
"""Test junction equals case temperature when no power dissipated."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# No power applied (output disabled by default)
|
||||||
|
state = engine.get_thermal_state()
|
||||||
|
|
||||||
|
# Junction should equal case (no θ_jc rise with zero power)
|
||||||
|
assert state.junction_temperature == pytest.approx(state.case_temperature)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelfHeating:
|
||||||
|
"""Tests for self-heating effects from power dissipation."""
|
||||||
|
|
||||||
|
def test_junction_higher_than_case_with_power(self) -> None:
|
||||||
|
"""Test junction temperature rises above case when power is dissipated."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Apply power: 5V input, 100mA load
|
||||||
|
engine.set_input_voltage(5.0)
|
||||||
|
engine.set_load_current(0.1)
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
# Let it settle
|
||||||
|
for _ in range(1000):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
thermal_state = engine.get_thermal_state()
|
||||||
|
electrical_state = engine.get_electrical_state()
|
||||||
|
|
||||||
|
# Power dissipation = (Vin - Vout) * Iload + Vin * Iq
|
||||||
|
# With Vout ≈ 3.3V, P ≈ (5-3.3)*0.1 ≈ 0.17W
|
||||||
|
assert electrical_state.power_dissipation > 0
|
||||||
|
|
||||||
|
# Junction should be higher than case by P * θ_jc
|
||||||
|
# θ_jc = 15°C/W, so with 0.17W, ΔT ≈ 2.5°C
|
||||||
|
assert (
|
||||||
|
thermal_state.junction_temperature > thermal_state.case_temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_self_heating_raises_case_temperature(self) -> None:
|
||||||
|
"""Test self-heating raises case temperature above ambient."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Apply significant power: 5V input, 300mA load
|
||||||
|
engine.set_input_voltage(5.0)
|
||||||
|
engine.set_load_current(0.3)
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
# Let thermal state settle (many time constants)
|
||||||
|
for _ in range(50000): # 500 seconds at 100Hz
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
thermal_state = engine.get_thermal_state()
|
||||||
|
|
||||||
|
# Power ≈ (5-3.3)*0.3 ≈ 0.51W
|
||||||
|
# Steady-state case rise = P * θ_ca = 0.51 * 5 ≈ 2.5°C
|
||||||
|
# Case should be above chamber temperature
|
||||||
|
assert (
|
||||||
|
thermal_state.case_temperature > thermal_state.chamber_temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_self_heating_increases_with_load(self) -> None:
|
||||||
|
"""Test that self-heating increases with higher load current."""
|
||||||
|
engine1 = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
engine2 = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Both at 5V, but different loads
|
||||||
|
for engine, load in [(engine1, 0.1), (engine2, 0.3)]:
|
||||||
|
engine.set_input_voltage(5.0)
|
||||||
|
engine.set_load_current(load)
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
# Let settle
|
||||||
|
for _ in range(50000):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
state1 = engine1.get_thermal_state()
|
||||||
|
state2 = engine2.get_thermal_state()
|
||||||
|
|
||||||
|
# Higher load should give higher junction temperature
|
||||||
|
assert (
|
||||||
|
state2.junction_temperature > state1.junction_temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemperatureDependentElectrical:
|
||||||
|
"""Tests for temperature-dependent electrical behaviour."""
|
||||||
|
|
||||||
|
def test_output_voltage_varies_with_temperature(self) -> None:
|
||||||
|
"""Test output voltage changes with junction temperature."""
|
||||||
|
# Create custom LDO with higher tempco for observable effect
|
||||||
|
params = LDOParameters(
|
||||||
|
nominal_output_voltage=3.3,
|
||||||
|
tempco_ppm_per_c=100.0, # 100 ppm/°C for visible effect
|
||||||
|
)
|
||||||
|
ldo = LDOModel(params=params)
|
||||||
|
|
||||||
|
# Test at different temperatures
|
||||||
|
vout_25 = ldo.calculate_output_voltage(25.0)
|
||||||
|
vout_85 = ldo.calculate_output_voltage(85.0)
|
||||||
|
|
||||||
|
# At 85°C with 100 ppm/°C:
|
||||||
|
# ΔV = 3.3 * 100e-6 * 60 = 19.8mV
|
||||||
|
delta_v = vout_85 - vout_25
|
||||||
|
expected_delta = 3.3 * 100e-6 * 60
|
||||||
|
|
||||||
|
assert delta_v == pytest.approx(expected_delta, rel=0.01)
|
||||||
|
|
||||||
|
def test_quiescent_current_varies_with_temperature(self) -> None:
|
||||||
|
"""Test quiescent current changes with junction temperature."""
|
||||||
|
ldo = LDOModel()
|
||||||
|
|
||||||
|
# Test at different temperatures
|
||||||
|
iq_25 = ldo.calculate_quiescent_current(25.0)
|
||||||
|
iq_85 = ldo.calculate_quiescent_current(85.0)
|
||||||
|
|
||||||
|
# Default tempco is 0.003/°C (0.3%/°C)
|
||||||
|
# At 85°C: Iq = Iq_25 * (1 + 0.003 * 60) = Iq_25 * 1.18
|
||||||
|
expected_ratio = 1.0 + 0.003 * 60
|
||||||
|
|
||||||
|
assert iq_85 / iq_25 == pytest.approx(expected_ratio, rel=0.01)
|
||||||
|
|
||||||
|
def test_power_dissipation_calculation(self) -> None:
|
||||||
|
"""Test power dissipation is calculated correctly."""
|
||||||
|
ldo = LDOModel()
|
||||||
|
ldo.input_voltage = 5.0
|
||||||
|
|
||||||
|
# P = (Vin - Vout) * Iload + Vin * Iq
|
||||||
|
# At 25°C: Vout ≈ 3.3V, Iq ≈ 50µA
|
||||||
|
# With 100mA load: P ≈ (5-3.3)*0.1 + 5*50e-6 ≈ 0.170W
|
||||||
|
p_diss = ldo.calculate_power_dissipation(
|
||||||
|
input_voltage=5.0,
|
||||||
|
load_current=0.1,
|
||||||
|
junction_temperature=25.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_p = (5.0 - 3.3) * 0.1 + 5.0 * 50e-6
|
||||||
|
assert p_diss == pytest.approx(expected_p, rel=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPhysicsEngineCoupling:
|
||||||
|
"""Tests for complete thermal-electrical coupling in the engine."""
|
||||||
|
|
||||||
|
def test_thermal_electrical_feedback(self) -> None:
|
||||||
|
"""Test that thermal and electrical states are coupled.
|
||||||
|
|
||||||
|
Higher junction temperature affects Vout, which affects power
|
||||||
|
dissipation, which affects junction temperature - a feedback loop.
|
||||||
|
"""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Apply power at hot chamber
|
||||||
|
engine.set_chamber_setpoint(85.0)
|
||||||
|
engine.set_input_voltage(5.0)
|
||||||
|
engine.set_load_current(0.2)
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
# Let settle completely
|
||||||
|
for _ in range(100000): # 1000 seconds
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
thermal = engine.get_thermal_state()
|
||||||
|
electrical = engine.get_electrical_state()
|
||||||
|
|
||||||
|
# Verify the coupling:
|
||||||
|
# 1. Chamber should be near 85°C
|
||||||
|
assert thermal.chamber_temperature == pytest.approx(85.0, abs=0.5)
|
||||||
|
|
||||||
|
# 2. Case should be higher than chamber due to self-heating
|
||||||
|
assert thermal.case_temperature > thermal.chamber_temperature
|
||||||
|
|
||||||
|
# 3. Junction should be higher than case
|
||||||
|
assert thermal.junction_temperature > thermal.case_temperature
|
||||||
|
|
||||||
|
# 4. Output voltage should reflect temperature-adjusted value
|
||||||
|
assert electrical.output_voltage > 0
|
||||||
|
assert electrical.output_voltage < 5.0 # Less than input
|
||||||
|
|
||||||
|
# 5. Power should be non-zero
|
||||||
|
assert electrical.power_dissipation > 0
|
||||||
|
|
||||||
|
def test_engine_with_custom_dut_model(self) -> None:
|
||||||
|
"""Test engine works with custom DUT model parameters."""
|
||||||
|
params = LDOParameters(
|
||||||
|
nominal_output_voltage=1.8, # Different voltage
|
||||||
|
tempco_ppm_per_c=25.0,
|
||||||
|
)
|
||||||
|
ldo = LDOModel(params=params)
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0, dut_model=ldo)
|
||||||
|
|
||||||
|
engine.set_input_voltage(3.3)
|
||||||
|
engine.set_load_current(0.1)
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
for _ in range(1000):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
electrical = engine.get_electrical_state()
|
||||||
|
|
||||||
|
# Should output approximately 1.8V
|
||||||
|
assert electrical.output_voltage == pytest.approx(1.8, abs=0.01)
|
||||||
|
|
||||||
|
def test_simulation_time_accuracy(self) -> None:
|
||||||
|
"""Test simulation time accumulates correctly."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=1000.0) # 1ms timestep
|
||||||
|
|
||||||
|
for _ in range(1000):
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
# Should be exactly 1 second
|
||||||
|
assert engine.simulation_time == pytest.approx(1.0, abs=1e-6)
|
||||||
|
|
||||||
|
def test_multiple_setpoint_changes(self) -> None:
|
||||||
|
"""Test engine handles multiple setpoint changes correctly."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
# Start at 25°C, go to 85°C
|
||||||
|
engine.set_chamber_setpoint(85.0)
|
||||||
|
for _ in range(10000): # 100 seconds
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
hot_state = engine.get_thermal_state()
|
||||||
|
assert hot_state.chamber_temperature > 75.0
|
||||||
|
|
||||||
|
# Now cool down to -40°C
|
||||||
|
engine.set_chamber_setpoint(-40.0)
|
||||||
|
for _ in range(20000): # 200 seconds
|
||||||
|
engine.step()
|
||||||
|
|
||||||
|
cold_state = engine.get_thermal_state()
|
||||||
|
assert cold_state.chamber_temperature < -30.0
|
||||||
214
tests/unit/test_physics_models.py
Normal file
214
tests/unit/test_physics_models.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"""Unit tests for physics state dataclasses and engine stub."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
|
||||||
|
from py_dvt_ate.simulation.physics.state import ElectricalState, ThermalState
|
||||||
|
|
||||||
|
|
||||||
|
class TestThermalState:
|
||||||
|
"""Tests for the ThermalState dataclass."""
|
||||||
|
|
||||||
|
def test_creation(self) -> None:
|
||||||
|
"""Test ThermalState can be created with valid values."""
|
||||||
|
state = ThermalState(
|
||||||
|
chamber_temperature=25.0,
|
||||||
|
case_temperature=30.0,
|
||||||
|
junction_temperature=35.0,
|
||||||
|
timestamp=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.chamber_temperature == 25.0
|
||||||
|
assert state.case_temperature == 30.0
|
||||||
|
assert state.junction_temperature == 35.0
|
||||||
|
assert state.timestamp == 0.0
|
||||||
|
|
||||||
|
def test_immutability(self) -> None:
|
||||||
|
"""Test ThermalState is immutable (frozen dataclass)."""
|
||||||
|
state = ThermalState(
|
||||||
|
chamber_temperature=25.0,
|
||||||
|
case_temperature=30.0,
|
||||||
|
junction_temperature=35.0,
|
||||||
|
timestamp=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
state.chamber_temperature = 50.0 # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_equality(self) -> None:
|
||||||
|
"""Test ThermalState equality comparison."""
|
||||||
|
state1 = ThermalState(
|
||||||
|
chamber_temperature=25.0,
|
||||||
|
case_temperature=30.0,
|
||||||
|
junction_temperature=35.0,
|
||||||
|
timestamp=0.0,
|
||||||
|
)
|
||||||
|
state2 = ThermalState(
|
||||||
|
chamber_temperature=25.0,
|
||||||
|
case_temperature=30.0,
|
||||||
|
junction_temperature=35.0,
|
||||||
|
timestamp=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state1 == state2
|
||||||
|
|
||||||
|
def test_hashable(self) -> None:
|
||||||
|
"""Test ThermalState is hashable (for use in sets/dicts)."""
|
||||||
|
state = ThermalState(
|
||||||
|
chamber_temperature=25.0,
|
||||||
|
case_temperature=30.0,
|
||||||
|
junction_temperature=35.0,
|
||||||
|
timestamp=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
hash(state)
|
||||||
|
{state} # Can be added to a set
|
||||||
|
|
||||||
|
|
||||||
|
class TestElectricalState:
|
||||||
|
"""Tests for the ElectricalState dataclass."""
|
||||||
|
|
||||||
|
def test_creation(self) -> None:
|
||||||
|
"""Test ElectricalState can be created with valid values."""
|
||||||
|
state = ElectricalState(
|
||||||
|
input_voltage=5.0,
|
||||||
|
output_voltage=3.3,
|
||||||
|
load_current=0.1,
|
||||||
|
quiescent_current=50e-6,
|
||||||
|
power_dissipation=0.17,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.input_voltage == 5.0
|
||||||
|
assert state.output_voltage == 3.3
|
||||||
|
assert state.load_current == 0.1
|
||||||
|
assert state.quiescent_current == 50e-6
|
||||||
|
assert state.power_dissipation == 0.17
|
||||||
|
|
||||||
|
def test_immutability(self) -> None:
|
||||||
|
"""Test ElectricalState is immutable (frozen dataclass)."""
|
||||||
|
state = ElectricalState(
|
||||||
|
input_voltage=5.0,
|
||||||
|
output_voltage=3.3,
|
||||||
|
load_current=0.1,
|
||||||
|
quiescent_current=50e-6,
|
||||||
|
power_dissipation=0.17,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
state.output_voltage = 1.8 # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_equality(self) -> None:
|
||||||
|
"""Test ElectricalState equality comparison."""
|
||||||
|
state1 = ElectricalState(
|
||||||
|
input_voltage=5.0,
|
||||||
|
output_voltage=3.3,
|
||||||
|
load_current=0.1,
|
||||||
|
quiescent_current=50e-6,
|
||||||
|
power_dissipation=0.17,
|
||||||
|
)
|
||||||
|
state2 = ElectricalState(
|
||||||
|
input_voltage=5.0,
|
||||||
|
output_voltage=3.3,
|
||||||
|
load_current=0.1,
|
||||||
|
quiescent_current=50e-6,
|
||||||
|
power_dissipation=0.17,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state1 == state2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPhysicsEngineStub:
|
||||||
|
"""Tests for the PhysicsEngine stub."""
|
||||||
|
|
||||||
|
def test_creation_default(self) -> None:
|
||||||
|
"""Test PhysicsEngine can be created with defaults."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
assert engine.dt == pytest.approx(0.01) # 100Hz -> 10ms
|
||||||
|
|
||||||
|
def test_creation_custom_rate(self) -> None:
|
||||||
|
"""Test PhysicsEngine with custom update rate."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=50.0)
|
||||||
|
|
||||||
|
assert engine.dt == pytest.approx(0.02) # 50Hz -> 20ms
|
||||||
|
|
||||||
|
def test_step_advances_time(self) -> None:
|
||||||
|
"""Test step() advances simulation time."""
|
||||||
|
engine = PhysicsEngine(update_rate_hz=100.0)
|
||||||
|
|
||||||
|
assert engine.simulation_time == pytest.approx(0.0)
|
||||||
|
engine.step()
|
||||||
|
assert engine.simulation_time == pytest.approx(0.01)
|
||||||
|
engine.step()
|
||||||
|
assert engine.simulation_time == pytest.approx(0.02)
|
||||||
|
|
||||||
|
def test_get_thermal_state(self) -> None:
|
||||||
|
"""Test get_thermal_state returns ThermalState."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
state = engine.get_thermal_state()
|
||||||
|
|
||||||
|
assert isinstance(state, ThermalState)
|
||||||
|
assert state.chamber_temperature == 25.0 # Default
|
||||||
|
assert state.timestamp == 0.0
|
||||||
|
|
||||||
|
def test_get_electrical_state(self) -> None:
|
||||||
|
"""Test get_electrical_state returns ElectricalState."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
state = engine.get_electrical_state()
|
||||||
|
|
||||||
|
assert isinstance(state, ElectricalState)
|
||||||
|
|
||||||
|
def test_set_chamber_setpoint(self) -> None:
|
||||||
|
"""Test set_chamber_setpoint updates setpoint."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
engine.set_chamber_setpoint(85.0)
|
||||||
|
|
||||||
|
# Stub doesn't update chamber temp, just stores setpoint
|
||||||
|
state = engine.get_thermal_state()
|
||||||
|
assert state.chamber_temperature == 25.0 # Still at initial
|
||||||
|
|
||||||
|
def test_set_input_voltage(self) -> None:
|
||||||
|
"""Test set_input_voltage updates voltage."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
engine.set_input_voltage(5.0)
|
||||||
|
|
||||||
|
state = engine.get_electrical_state()
|
||||||
|
assert state.input_voltage == 5.0
|
||||||
|
|
||||||
|
def test_set_load_current(self) -> None:
|
||||||
|
"""Test set_load_current updates current."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
engine.set_load_current(0.1)
|
||||||
|
|
||||||
|
state = engine.get_electrical_state()
|
||||||
|
assert state.load_current == 0.1
|
||||||
|
|
||||||
|
def test_output_disabled_by_default(self) -> None:
|
||||||
|
"""Test output is disabled by default."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
assert not engine.is_output_enabled
|
||||||
|
|
||||||
|
def test_enable_output(self) -> None:
|
||||||
|
"""Test set_output_enabled enables output."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
|
||||||
|
engine.set_output_enabled(True)
|
||||||
|
|
||||||
|
assert engine.is_output_enabled
|
||||||
|
|
||||||
|
def test_load_current_zero_when_disabled(self) -> None:
|
||||||
|
"""Test load current is zero when output disabled."""
|
||||||
|
engine = PhysicsEngine()
|
||||||
|
engine.set_load_current(0.1)
|
||||||
|
|
||||||
|
state = engine.get_electrical_state()
|
||||||
|
|
||||||
|
assert state.load_current == 0.0 # Disabled, so zero
|
||||||
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)
|
||||||
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"
|
||||||
Reference in New Issue
Block a user