36 Commits

Author SHA1 Message Date
9e9c0ae0e5 Release v0.1.0-alpha.3
Some checks failed
CI / Lint (push) Failing after 4s
CI / Type Check (push) Failing after 17s
CI / Test (push) Successful in 9s
CI / Release (push) Has been cancelled
2025-06-02 22:56:53 +00:00
a742d57a6f Add TCP server integration tests
Test connection handling, multiple clients, instrument access across
all three virtual instruments, physics engine integration, and error
handling. Update pytest-asyncio config for v1.x compatibility.
2025-05-30 22:59:33 +00:00
2d358062f4 Add simulation server entry point
Create SimulationServer that wires physics engine to all virtual
instruments and exposes them over TCP. Add 'serve' CLI command to
start the server with configurable ports and physics rate.
2025-05-30 19:31:01 +00:00
1a489b9106 Implement TCP client handling
Add async client connection handling with:
- Multiple concurrent connections per port
- Line-based SCPI protocol (newline terminated)
- start(), stop(), and serve_forever() methods
- Proper connection lifecycle and error handling
2025-05-24 13:48:10 +00:00
f9e59da32b Add async TCP server foundation
Create InstrumentServer class with asyncio for hosting virtual SCPI
instruments over TCP. Supports registering instruments on specific
ports with port-to-instrument mapping.
2025-05-22 21:32:38 +00:00
a4c01c856d Add multimeter simulator tests
Comprehensive test coverage for MultimeterSim including MEAS:VOLT:DC,
MEAS:CURR:DC, CONF, and READ commands. Tests both standalone operation
and physics engine integration including temperature-dependent measurements.
2025-05-21 22:55:40 +00:00
144e80f87a Add multimeter simulator
Implement SCPI-based virtual DMM with DC voltage and current measurement.
Supports MEAS, CONF, and READ commands. Integrates with physics engine
for DUT output measurements.
2025-05-16 23:48:11 +00:00
e811b21082 Add power supply simulator tests
Comprehensive test coverage for PowerSupplySim including VOLT, CURR,
OUTP, and MEAS commands. Tests both standalone operation and physics
engine integration.
2025-05-12 17:29:00 +00:00
9a88a35cc5 Add power supply simulator
Implement SCPI-based virtual power supply with voltage/current control
and output enable commands. Integrates with physics engine for DUT
input voltage simulation.
2025-05-09 20:21:07 +00:00
b31324a42a Add thermal chamber simulator tests
Tests for ThermalChamberSim SCPI command responses:
- Basic IEEE 488.2 commands (*IDN?, *RST, *OPC?)
- TEMP:SETPOINT set/query
- TEMP:ACTUAL? query
- TEMP:STAB? stability query
- Physics engine integration tests
2025-05-05 21:00:43 +00:00
008134844d Implement thermal chamber SCPI commands
- TEMP:SETPOINT: Set/query target temperature
- TEMP:ACTUAL?: Query actual chamber temperature from physics engine
- TEMP:STAB?: Query temperature stability (within 0.5°C threshold)
2025-05-04 19:34:48 +00:00
ae85948539 Add thermal chamber simulator stub
Defines ThermalChamberSim class with stub SCPI command handlers for
TEMP:SETPOINT, TEMP:ACTUAL?, and TEMP:STAB? commands.
2025-05-02 23:33:16 +00:00
bccb8cc420 Add base instrument class
Provides SCPI command parsing and dispatch mechanism for virtual
instruments. Includes IEEE 488.2 common commands (*IDN?, *RST, *CLS,
*OPC) and abstract methods for instrument-specific implementations.
2025-04-28 19:24:24 +00:00
510e1ba683 Add SCPI parser tests
Comprehensive test suite for SCPI command parsing:
- SCPICommand dataclass tests (creation, keyword property)
- Parser tests for queries, commands, arguments
- IEEE 488.2 common command tests (*IDN?, *RST, etc.)
- Edge cases (whitespace, empty strings)
- Instrument-specific command tests

Also fixed bug where is_query was determined from command string
ending rather than header ending (handles queries with arguments).
2025-04-21 13:10:50 +00:00
5e69085875 Implement SCPI parser
Adds SCPIParser class with parse() method that handles:
- IEEE 488.2 common commands (*IDN?, *RST, etc.)
- Query commands (ending with '?')
- Commands with comma-separated arguments
- Whitespace stripping
2025-04-21 12:03:55 +00:00
5053399851 Add SCPI command dataclass
Defines SCPICommand dataclass for parsed SCPI commands with:
- header: command header (e.g., "TEMP:SETPOINT")
- arguments: list of command arguments
- is_query: whether command is a query
- keyword property: header without trailing '?'
2025-04-16 23:08:32 +00:00
d54ada18b2 Remove fragment from sidebar controls (not supported)
Sidebar controls cannot be in a fragment. Brief blank on
slider change is a Streamlit limitation.
2025-04-15 21:25:23 +00:00
252c329562 Put sidebar controls in fragment to prevent page blanking
Both controls and display are now fragments, so slider
changes don't trigger full page reruns.
2025-04-13 18:47:37 +00:00
6e7da7f382 Use st.fragment for smooth dashboard updates
Replace st.rerun() with @st.fragment decorator to prevent
full page reloads and eliminate UI greying out.
2025-04-08 13:08:15 +00:00
75e0a1cc25 Fix dashboard simulation speed with time multiplier
- Add time multiplier control (1× to 100× speed)
- Calculate steps based on real elapsed time
- Add 50ms delay to prevent UI thrashing
- Display current speed in Sim Time metric
2025-04-05 17:58:41 +00:00
1c0d2ead54 Release v0.1.0-alpha.2
Some checks failed
CI / Test (push) Successful in 9s
CI / Release (push) Has been cancelled
CI / Lint (push) Failing after 4s
CI / Type Check (push) Failing after 17s
2025-04-03 21:20:13 +00:00
2b78a75f51 Add self-heating visualisation 2025-03-29 17:04:13 +00:00
15c9033153 Add interactive physics controls 2025-03-24 15:02:51 +00:00
0ab1181ec4 Add physics visualisation panel 2025-03-24 14:20:53 +00:00
bb3129e69b Add Streamlit dashboard skeleton 2025-03-18 14:24:17 +00:00
14858a087c Release v0.1.0-alpha.1
Some checks failed
CI / Release (push) Has been cancelled
CI / Lint (push) Failing after 4s
CI / Type Check (push) Failing after 7s
CI / Test (push) Successful in 8s
Physics engine working milestone:
- Thermal-electrical coupling simulation
- LDO DUT model with temperature dependence
- Comprehensive test suite
2025-03-14 19:10:34 +00:00
7ecdbe007a Add physics engine tests
Integration tests for thermal-electrical coupling:
- Thermal settling (chamber, case, junction)
- Self-heating effects with power dissipation
- Temperature-dependent electrical behaviour
- Complete thermal-electrical feedback loop
2025-03-13 16:42:17 +00:00
568d1a6ca4 Implement physics engine stepping
Full implementation of step() method with thermal-electrical coupling:
- Chamber temperature first-order response to setpoint
- Case temperature with self-heating via thermal calculations
- Junction temperature from θ_jc thermal resistance
- Electrical state from temperature-dependent DUT model
- Default LDO model when none provided
2025-03-11 19:23:10 +00:00
3db4969e44 Implement LDO DUT model
Temperature-dependent LDO voltage regulator model with:
- Output voltage tempco (ppm/°C)
- Quiescent current tempco
- Dropout voltage temperature dependence
- Power dissipation calculation (Vin-Vout)*Iload + Vin*Iq
- Dropout detection

Implements DUTModel protocol for physics engine integration.
2025-03-06 21:24:17 +00:00
8ef8c18e50 Implement thermal calculation functions
Pure functions for first-order thermal response calculations:
- Temperature derivative and update using Euler integration
- Case temperature with self-heating via θ_ca
- Junction temperature calculation via θ_jc
- Steady-state junction temperature helper
2025-03-02 20:05:54 +00:00
eb13bb5bc4 Add physics model unit tests
Test dataclass creation, immutability, equality, and hashability for
ThermalState and ElectricalState. Also test PhysicsEngine stub methods.
2025-02-27 21:23:36 +00:00
ca4613e318 Rename models.py to state.py to avoid conflict with models/ directory
The models.py file conflicts with the models/ subdirectory when
importing. Renamed to state.py for clarity.
2025-02-27 19:02:42 +00:00
7ca31c9c97 Add physics engine stub
Define PhysicsEngine class with stub methods for thermal-electrical
simulation. Methods return placeholder values; full implementation
will be added in Sprint 3.
2025-02-20 18:59:24 +00:00
13d53d13df Add DUT model protocol
Define the DUTModel Protocol interface that all device models must
implement to integrate with the physics engine.
2025-02-20 18:53:40 +00:00
6a937876a3 Add physics state dataclasses
Define frozen dataclasses for ThermalState and ElectricalState to
represent immutable simulation state snapshots.
2025-02-15 19:51:02 +00:00
85024f8670 Restructure package for domain-driven design
Reorganise package structure to improve separation of concerns:
- instruments/ - SCPI, transport, drivers, interfaces, factory
- simulation/ - physics engine, virtual instruments, server
- framework/ - test runner, logger, limits, context
- tests/ - thermal/, electrical/ (DVT test implementations)
- data/ - repository, models
- reporting/ - generator, templates
- app/ - CLI, config, dashboard

This structure enables:
- Reusable instruments package for other test suites
- Clear separation of simulation (dev) vs production code
- Domain-focused package organisation

Updated documentation to reflect new paths.
2025-02-10 12:06:22 +00:00
55 changed files with 4699 additions and 406 deletions

View File

@@ -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 |

View File

@@ -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"]

View File

@@ -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 |

View File

@@ -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"

View File

@@ -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"

View 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
View 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()

View 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"]

View 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()

View File

@@ -1 +0,0 @@
"""Command-line interface."""

View File

@@ -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()

View File

@@ -1 +0,0 @@
"""Configuration handling."""

View File

@@ -1 +0,0 @@
"""Streamlit dashboard."""

View File

@@ -1 +0,0 @@
"""Instrument SCPI drivers."""

View File

@@ -1 +0,0 @@
"""Test execution framework."""

View File

@@ -0,0 +1,5 @@
"""Test execution framework.
Provides test sequencing, measurement logging, limit checking,
and runtime context management for DVT characterisation tests.
"""

View File

@@ -1 +0,0 @@
"""Hardware Abstraction Layer."""

View File

@@ -1 +0,0 @@
"""HAL implementations."""

View File

@@ -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
"""

View 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.
"""

View 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,
)

View 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)
"""

View File

@@ -1 +0,0 @@
"""Physics simulation engine."""

View File

@@ -1 +0,0 @@
"""Device Under Test models."""

View File

@@ -0,0 +1,5 @@
"""Report generation.
Generates test reports from stored data in various formats
including PDF and HTML.
"""

View File

@@ -1 +0,0 @@
"""Simulation server."""

View 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"]

View 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.
"""

View 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

View 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"]

View 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.
"""
...

View 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

View 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

View 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)

View 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()

View 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)

View 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.
"""

View 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.
"""

View 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"

View 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}"

View 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}"

View File

@@ -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.)
"""

View File

@@ -0,0 +1,5 @@
"""Electrical characterisation tests.
Tests for electrical performance including load regulation,
line regulation, and output accuracy.
"""

View File

@@ -0,0 +1,5 @@
"""Thermal characterisation tests.
Tests related to temperature behaviour including temperature
coefficient (TempCo) measurements and thermal stability.
"""

View File

@@ -1 +0,0 @@
"""Communication layer."""

View File

@@ -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")

View File

@@ -0,0 +1 @@
"""Integration tests for py_dvt_ate."""

View File

@@ -0,0 +1 @@
"""Configuration for integration tests."""

View 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()

View 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

View 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

View 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

View 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)

View 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

View 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"