11 Commits

Author SHA1 Message Date
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
38 changed files with 1610 additions and 363 deletions

View File

@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Added
@@ -30,5 +44,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| 0.1.0-beta.1 | TBD | HAL complete |
| 0.1.0-alpha.3 | TBD | Network ready |
| 0.1.0-alpha.2 | TBD | 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 |

View File

@@ -214,7 +214,7 @@ For **why** decisions were made, see `03_architecture_decisions.md`.
### 2.1 Directory Layout
```
thermaulate/
py_dvt_ate/
├── pyproject.toml # Project metadata and dependencies
├── README.md # Project overview and quick start
├── CHANGELOG.md # Version history
@@ -222,205 +222,163 @@ thermaulate/
├── docs/
│ ├── 01_requirements.md # Business Requirements
│ ├── 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/
── thermaulate/
├── src/py_dvt_ate/
── __init__.py # Package version
│ ├── py.typed # PEP 561 marker
│ │
│ ├── instruments/ # INSTRUMENT CONTROL (reusable)
│ │ ├── __init__.py
│ │ ├── interfaces.py # IThermalChamber, IPowerSupply, IMultimeter
│ │ ├── scpi.py # SCPI parser (shared protocol)
│ │ ├── factory.py # Creates instrument sets from config
│ │ ├── 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
│ │ ├── base.py # Base driver
│ │ ├── chamber.py # Thermal chamber driver
│ │ ├── power_supply.py # PSU driver
│ │ └── multimeter.py # DMM driver
│ │
│ ├── simulation/ # PHYSICS SIMULATION (dev/test only)
│ │ ├── __init__.py
│ │ ├── server.py # TCP server hosting virtual instruments
│ │ ├── physics/ # Physics engine
│ │ │ ├── __init__.py
│ │ │ ├── engine.py # Main simulation loop
│ │ │ ├── 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
│ │
│ ├── framework/ # TEST FRAMEWORK (reusable)
│ │ ├── __init__.py
│ │ ├── runner.py # Test sequencer
│ │ ├── context.py # Runtime context
│ │ ├── logger.py # Measurement logging
│ │ ├── limits.py # Pass/fail evaluation
│ │ └── models.py # Framework models
│ │
│ ├── tests/ # DVT TEST IMPLEMENTATIONS
│ │ ├── __init__.py
│ │ ├── base.py # Base test class
│ │ ├── thermal/ # Thermal characterisation tests
│ │ │ ├── __init__.py
│ │ │ └── tempco.py # Temperature coefficient test
│ │ └── electrical/ # Electrical characterisation tests
│ │ ├── __init__.py
│ │ └── load_regulation.py # Load regulation test
│ │
│ ├── data/ # DATA PERSISTENCE (shared)
│ │ ├── __init__.py
│ │ ├── repository.py # Data access layer
│ │ └── models.py # Data models
│ │
│ ├── reporting/ # REPORT GENERATION (standalone)
│ │ ├── __init__.py
│ │ ├── generator.py # Report generator
│ │ └── templates/ # Report templates
│ │
│ └── app/ # APPLICATION ENTRY POINTS
│ ├── __init__.py
│ ├── py.typed # PEP 561 marker
── physics/ # Physics simulation engine
│ │ ├── __init__.py
│ │ ├── engine.py # Main physics loop
│ │ ├── thermal.py # Thermal domain model
│ │ ├── electrical.py # Electrical domain model
│ │ └── dut/
│ │ ├── __init__.py
│ │ ├── base.py # DUT base class
│ │ └── ldo.py # LDO voltage regulator model
│ │
│ ├── instruments/ # Virtual instrument implementations
│ │ ├── __init__.py
│ │ ├── base.py # Instrument base class
│ │ ├── scpi_parser.py # SCPI command parser
│ │ ├── thermal_chamber.py # Thermal chamber simulator
│ │ ├── power_supply.py # Power supply simulator
│ │ └── multimeter.py # DMM simulator
│ │
│ ├── server/ # Simulation server
│ │ ├── __init__.py
│ │ ├── tcp_server.py # Async TCP server
│ │ └── main.py # Server entry point
│ │
│ ├── transport/ # Communication layer
│ │ ├── __init__.py
│ │ ├── base.py # Transport protocol
│ │ ├── tcp.py # TCP/IP implementation
│ │ └── async_tcp.py # Async TCP implementation
│ │
│ ├── drivers/ # Instrument SCPI drivers
│ │ ├── __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
│ │
│ ├── data/ # Data persistence
│ │ ├── __init__.py
│ │ ├── repository.py # Data access layer
│ │ ├── models.py # Data models
│ │ └── migrations/ # Schema migrations
│ │
│ ├── reporting/ # Report generation (Phase 3)
│ │ ├── __init__.py
│ │ ├── generator.py # Report generator
│ │ ├── pdf.py # PDF output
│ │ ├── html.py # HTML output
│ │ └── templates/ # Report templates
│ │
│ ├── api/ # REST API (Phase 2)
│ │ ├── __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
│ ├── cli.py # Command-line interface
├── config.py # YAML loading
── dashboard/ # Streamlit dashboard
│ ├── __init__.py
── models.py # Pydantic config models
│ └── loader.py # Config file loader
── app.py # Main Streamlit app
├── tests/ # Test suite
│ ├── conftest.py # pytest fixtures
│ ├── unit/
│ ├── test_physics_engine.py
│ │ ├── test_scpi_parser.py
│ │ ├── test_thermal_model.py
│ │ └── ...
│ └── integration/
│ ├── test_instrument_communication.py
│ ├── test_tempco_sequence.py
│ └── ...
├── tests/ # pytest test suite
│ ├── conftest.py # pytest fixtures
│ ├── unit/ # Unit tests
└── integration/ # Integration tests
├── config/ # Configuration files
── default.yaml # Default configuration
│ └── example_pyvisa.yaml # Example for real hardware
├── config/ # Configuration files
── default.yaml # Default configuration
── docker/
├── Dockerfile.server # Simulation server image
├── Dockerfile.app # Test application image
└── docker-compose.yml # Full stack orchestration
└── scripts/
├── demo.py # Demo script
└── run_tempco.py # Example test execution
── docker/ # Docker deployment
├── Dockerfile.server # Simulation server image
├── Dockerfile.app # Test application image
└── docker-compose.yml # Full stack orchestration
```
### 2.2 Package Dependencies
```
thermaulate/
├── cli/ ──────────────────────────────────────────────┐
├── api/ ──────────────────────────────────────────────┤
├── dashboard/ ──────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PRESENTATION │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
├── executive/ ◄───────────────────────────────────────────────┤
├── tests/ ◄───────────────────────────────────────────────┤
├── reporting/ ◄───────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ APPLICATION │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
├── hal/interfaces ◄───────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DOMAIN (Abstractions) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ implements│ │
│ ▼ │
├── hal/impl ◄───────────────────────────────────────────────┤
├── drivers/ ◄───────────────────────────────────────────────┤
├── transport/ ◄───────────────────────────────────────────────┤
├── data/ ◄───────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ INFRASTRUCTURE │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Dependency Graph:
SIMULATION SERVER (Separate Process):
├── physics/ ◄─── Pure domain logic, no external dependencies
├── instruments/ ◄─── Depends on physics
└── server/ ◄─── Depends on instruments
app/ ──────────────▶ framework/ ──────────────▶ 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.1 Physics Module
### 3.1 Instruments Package
**Responsibility**: Simulate coupled thermal-electrical behaviour.
**Responsibility**: Everything about talking to lab instruments.
**Key Components**:
| Component | File | Purpose |
|-----------|------|---------|
| PhysicsEngine | `engine.py` | Main simulation loop, state management |
| ThermalModel | `thermal.py` | Heat transfer calculations |
| ElectricalModel | `electrical.py` | Current/voltage relationships |
| DUTBase | `dut/base.py` | Abstract DUT interface |
| LDOModel | `dut/ldo.py` | LDO voltage regulator implementation |
| Interfaces | `instruments/interfaces.py` | IThermalChamber, IPowerSupply, IMultimeter protocols |
| SCPIParser | `instruments/scpi.py` | Parse SCPI command strings |
| Factory | `instruments/factory.py` | Create instrument sets from config |
| Transport | `instruments/transport/` | TCP, VISA connection layer |
| 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**:
- 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**:
| Component | File | Purpose |
|-----------|------|---------|
| InstrumentBase | `base.py` | Common instrument functionality |
| SCPIParser | `scpi_parser.py` | Parse SCPI command strings |
| ThermalChamberSim | `thermal_chamber.py` | Chamber simulation |
| PowerSupplySim | `power_supply.py` | PSU simulation |
| MultimeterSim | `multimeter.py` | DMM simulation |
**Command Processing Flow**:
```
SCPI String → Parser → Command Object → Instrument Handler → Response
```
| TestRunner | `framework/runner.py` | Sequences test steps |
| TestContext | `framework/context.py` | Runtime context |
| TestLogger | `framework/logger.py` | Measurement logging |
| LimitChecker | `framework/limits.py` | Pass/fail evaluation |
| Models | `framework/models.py` | TestStatus, TestResult, etc. |
---
### 3.3 Transport Module
### 3.4 Data Package
**Responsibility**: Low-level communication.
**Responsibility**: Data persistence for test results.
**Key Components**:
| Component | File | Purpose |
|-----------|------|---------|
| Transport Protocol | `base.py` | Abstract transport interface |
| TCPTransport | `tcp.py` | Synchronous TCP implementation |
| AsyncTCPTransport | `async_tcp.py` | Async TCP implementation |
| Repository | `data/repository.py` | Data access layer |
| Models | `data/models.py` | TestRun, Measurement dataclasses |
---
### 3.4 Drivers Module
### 3.5 Reporting Package
**Responsibility**: Instrument-specific SCPI command sets.
**Responsibility**: Report generation from stored data.
**Key Components**:
| Component | File | Purpose |
|-----------|------|---------|
| DriverBase | `base.py` | Common driver functionality |
| ThermalChamberDriver | `thermal_chamber.py` | Chamber SCPI commands |
| PowerSupplyDriver | `power_supply.py` | PSU SCPI commands |
| MultimeterDriver | `multimeter.py` | DMM SCPI commands |
| Generator | `reporting/generator.py` | Creates reports from data |
| Templates | `reporting/templates/` | Report templates |
---
### 3.5 HAL Module
### 3.6 App Package
**Responsibility**: Hardware abstraction interfaces.
**Responsibility**: Application entry points.
**Key Components**:
| Component | File | Purpose |
|-----------|------|---------|
| Protocols | `interfaces.py` | Abstract interfaces |
| InstrumentFactory | `factory.py` | Creates instrument sets from config |
| HAL Implementations | `impl/*.py` | Concrete HAL classes |
---
### 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 |
| CLI | `app/cli.py` | Command-line interface (Typer) |
| Config | `app/config.py` | YAML loading, instance creation |
| Dashboard | `app/dashboard/app.py` | Streamlit application |
---
## 4. Interface Definitions
### 4.1 HAL Interfaces
### 4.1 Instrument Interfaces
```python
# thermaulate/hal/interfaces.py
# py_dvt_ate/instruments/interfaces.py
from typing import Protocol, runtime_checkable
@@ -664,7 +582,7 @@ class ITestLogger(Protocol):
### 4.2 Transport Interface
```python
# thermaulate/transport/base.py
# py_dvt_ate/instruments/transport/base.py
from typing import Protocol
@@ -701,7 +619,7 @@ class Transport(Protocol):
### 4.3 Test Interface
```python
# thermaulate/executive/models.py
# py_dvt_ate/framework/models.py
from dataclasses import dataclass, field
from datetime import datetime
@@ -778,12 +696,12 @@ class ITest(Protocol):
### 4.4 Factory Interface
```python
# thermaulate/hal/factory.py
# py_dvt_ate/instruments/factory.py
from dataclasses import dataclass
from typing import Literal
from thermaulate.hal.interfaces import IThermalChamber, IPowerSupply, IMultimeter
from py_dvt_ate.instruments.interfaces import IThermalChamber, IPowerSupply, IMultimeter
@dataclass
@@ -827,22 +745,19 @@ class InstrumentFactory:
@staticmethod
def _create_simulated(config: InstrumentConfig) -> InstrumentSet:
"""Create simulated instruments."""
from thermaulate.transport.tcp import TCPTransport
from thermaulate.drivers.thermal_chamber import ThermalChamberDriver
from thermaulate.drivers.power_supply import PowerSupplyDriver
from thermaulate.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
from py_dvt_ate.instruments.transport.tcp import TCPTransport
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
chamber_transport = TCPTransport(config.simulator_host, config.chamber_port)
psu_transport = TCPTransport(config.simulator_host, config.psu_port)
dmm_transport = TCPTransport(config.simulator_host, config.dmm_port)
return InstrumentSet(
chamber=ThermalChamberHAL(ThermalChamberDriver(chamber_transport)),
psu=PowerSupplyHAL(PowerSupplyDriver(psu_transport)),
dmm=MultimeterHAL(MultimeterDriver(dmm_transport)),
chamber=ThermalChamberDriver(chamber_transport),
psu=PowerSupplyDriver(psu_transport),
dmm=MultimeterDriver(dmm_transport),
)
@staticmethod
@@ -954,7 +869,7 @@ All instruments implement these standard commands:
### 5.5 SCPI Parser Specification
```python
# thermaulate/instruments/scpi_parser.py
# py_dvt_ate/instruments/scpi.py
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
```python
# thermaulate/physics/engine.py
# py_dvt_ate/simulation/physics/engine.py
from dataclasses import dataclass
@@ -1256,7 +1171,7 @@ Schema:
### 7.3 Data Repository Interface
```python
# thermaulate/data/repository.py
# py_dvt_ate/data/repository.py (interface)
from typing import Protocol
from uuid import UUID
@@ -1366,14 +1281,14 @@ dut:
# Data storage paths
data:
database_path: "./data/thermaulate.db"
database_path: "./data/py_dvt_ate.db"
measurements_dir: "./data/measurements"
reports_dir: "./data/reports"
# Logging configuration
logging:
level: INFO
file: "./data/logs/thermaulate.log"
file: "./data/logs/py_dvt_ate.log"
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# Dashboard (Streamlit)
@@ -1391,7 +1306,7 @@ api:
### 8.2 Pydantic Configuration Models
```python
# thermaulate/config/models.py
# py_dvt_ate/app/config.py (config models)
from pydantic import BaseModel, Field
from typing import Literal
@@ -1449,14 +1364,14 @@ class DUTConfig(BaseModel):
class DataConfig(BaseModel):
database_path: str = "./data/thermaulate.db"
database_path: str = "./data/py_dvt_ate.db"
measurements_dir: str = "./data/measurements"
reports_dir: str = "./data/reports"
class LoggingConfig(BaseModel):
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"
@@ -1612,7 +1527,7 @@ class AppConfig(BaseModel):
```toml
[project]
name = "thermaulate"
name = "py_dvt_ate"
version = "0.1.0"
description = "Coupled Physics DVT Simulation Platform"
requires-python = ">=3.11"
@@ -1648,9 +1563,9 @@ dev = [
]
[project.scripts]
thermaulate = "thermaulate.cli.main:app"
thermaulate-server = "thermaulate.server.main:main"
thermaulate-dashboard = "thermaulate.dashboard.app:main"
py_dvt_ate = "py_dvt_ate.cli.main:app"
py_dvt_ate-server = "py_dvt_ate.server.main:main"
py_dvt_ate-dashboard = "py_dvt_ate.dashboard.app:main"
[build-system]
requires = ["hatchling"]

View File

@@ -38,7 +38,7 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
**Vertical Slice Strategy:**
- Sprints 1-3: Foundation + Physics Engine (the core simulation)
- 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
---
@@ -79,20 +79,18 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
**Goal:** Define physics interfaces and data structures.
### Task 2.1: Define thermal state dataclasses
- Create src/py_dvt_ate/physics/__init__.py
- Create src/py_dvt_ate/physics/models.py
- Create src/py_dvt_ate/simulation/physics/models.py
- Define ThermalState (frozen dataclass)
- Define ElectricalState (frozen dataclass)
- **Commit:** "Add physics state dataclasses"
### Task 2.2: Define DUT base protocol
- Create src/py_dvt_ate/physics/dut/__init__.py
- Create src/py_dvt_ate/physics/dut/base.py
- Create src/py_dvt_ate/simulation/physics/models/base.py
- Define DUTModel Protocol with method signatures
- **Commit:** "Add DUT model protocol"
### 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
- Methods return placeholder values
- **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.
### 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
- Pure functions, no state
- **Commit:** "Implement thermal calculation functions"
### 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
- Temperature-dependent Vout, Iq calculations
- 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!
### Task 4.1: Create dashboard app skeleton
- Create src/py_dvt_ate/dashboard/__init__.py
- Create src/py_dvt_ate/dashboard/app.py
- Create src/py_dvt_ate/app/dashboard/__init__.py
- Create src/py_dvt_ate/app/dashboard/app.py
- Basic Streamlit page with title
- **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
- 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
- **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.
### 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
- Command dispatch mechanism
- **Commit:** "Add base instrument class"
### 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
- Stub SCPI command handlers
- **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.
### 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
- VOLT, CURR, OUTP, MEAS commands
- **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"
### 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
- MEAS:VOLT:DC?, CONF commands
- **Commit:** "Add multimeter simulator"
@@ -250,8 +248,8 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
**Goal:** Expose instruments over network.
### Task 8.1: Create async TCP server foundation
- Create src/py_dvt_ate/server/__init__.py
- Create src/py_dvt_ate/server/tcp_server.py
- Create src/py_dvt_ate/simulation/__init__.py
- Create src/py_dvt_ate/simulation/tcp_server.py
- Define InstrumentServer class with asyncio
- **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"
### 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
- Add CLI command to start server
- **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.
### Task 9.1: Define transport protocol
- Create src/py_dvt_ate/transport/__init__.py
- Create src/py_dvt_ate/transport/base.py
- Create src/py_dvt_ate/instruments/transport/__init__.py
- Create src/py_dvt_ate/instruments/transport/base.py
- Define Transport Protocol class
- **Commit:** "Add transport protocol definition"
### 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
- connect(), write(), read(), query() methods
- **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.
### Task 10.1: Define driver base class
- Create src/py_dvt_ate/drivers/__init__.py
- Create src/py_dvt_ate/drivers/base.py
- Create src/py_dvt_ate/instruments/drivers/__init__.py
- Create src/py_dvt_ate/instruments/drivers/base.py
- Define BaseDriver with transport dependency
- **Commit:** "Add driver base class"
### 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
- **Commit:** "Add thermal chamber driver"
### Task 10.3: Implement PSU and DMM drivers
- Create src/py_dvt_ate/drivers/power_supply.py
- Create src/py_dvt_ate/drivers/multimeter.py
- Create src/py_dvt_ate/instruments/drivers/power_supply.py
- Create src/py_dvt_ate/instruments/drivers/multimeter.py
- **Commit:** "Add PSU and DMM drivers"
### 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
- Create src/py_dvt_ate/hal/__init__.py
- Create src/py_dvt_ate/hal/interfaces.py
- Define IThermalChamber, IPowerSupply, IMultimeter
- **Commit:** "Add HAL protocol definitions"
### Task 11.1: Define instrument interface protocols
- Create src/py_dvt_ate/instruments/interfaces.py
- Define IThermalChamber, IPowerSupply, IMultimeter protocols
- **Commit:** "Add instrument interface protocols"
### Task 11.2: Implement HAL wrappers
- Create src/py_dvt_ate/hal/impl/__init__.py
- Create HAL implementation classes
- Wrap drivers with HAL interface
- **Commit:** "Add HAL implementations"
### Task 11.2: Ensure drivers implement interfaces
- Update drivers to satisfy Protocol interfaces
- Add type hints for interface compliance
- **Commit:** "Implement instrument interfaces in drivers"
### Task 11.3: Create instrument factory
- Create src/py_dvt_ate/hal/factory.py
- Create src/py_dvt_ate/instruments/factory.py
- InstrumentSet dataclass
- InstrumentFactory.create() method
- **Commit:** "Add instrument factory"
### Task 11.4: Add HAL tests
- Create tests/unit/test_hal.py
### Task 11.4: Add instrument interface tests
- Create tests/unit/test_instruments.py
- 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.
### Task 12.1: Define config models
- Create src/py_dvt_ate/config/__init__.py
- Create src/py_dvt_ate/config/models.py
- Pydantic models for all config sections
- Create src/py_dvt_ate/app/config.py
- Define Pydantic models for all config sections
- **Commit:** "Add configuration Pydantic models"
### 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
- Environment variable overrides
- **Commit:** "Implement configuration loader"
@@ -410,25 +405,25 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
**Goal:** Test execution orchestration.
### Task 14.1: Define test interface and models
- Create src/py_dvt_ate/executive/__init__.py
- Create src/py_dvt_ate/executive/models.py
- Create src/py_dvt_ate/framework/__init__.py
- Create src/py_dvt_ate/framework/context.py
- TestStatus enum, TestContext, ITest protocol
- **Commit:** "Add test executive models"
- **Commit:** "Add test framework models"
### 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
- **Commit:** "Implement test logger"
### 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
- **Commit:** "Implement limit checker"
### Task 14.4: Implement test sequencer
- Create src/py_dvt_ate/executive/sequencer.py
### Task 14.4: Implement test runner
- Create src/py_dvt_ate/framework/runner.py
- 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"
### 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
- Vout measurement at each temperature
- TempCo calculation
@@ -517,40 +512,38 @@ Each sprint is ~1-2 days of work, producing demonstrable progress.
## File Dependencies Map
```
physics/models.py → (none)
physics/dut/base.py → models.py
physics/dut/ldo.py → base.py, models.py
physics/thermal.py → models.py
physics/engine.py → models.py, thermal.py, dut/base.py
simulation/physics/models.py → (none)
simulation/physics/models/base.py → models.py
simulation/physics/models/ldo.py → base.py, models.py
simulation/physics/thermal.py → models.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/base.py → scpi_parser.py
instruments/*_sim.py → base.py, physics/engine.py
instruments/scpi.py → (none)
simulation/virtual/base.py → instruments/scpi.py
simulation/virtual/*.py → base.py, simulation/physics/engine.py
transport/base.py → (none)
transport/tcp.py → base.py
instruments/transport/base.py → (none)
instruments/transport/tcp.py → base.py
drivers/base.py → transport/base.py
drivers/*.py → base.py
instruments/drivers/base.py → instruments/transport/base.py
instruments/drivers/*.py → base.py
hal/interfaces.py → (none)
hal/impl/*.py → interfaces.py, drivers/*.py
hal/factory.py → interfaces.py, impl/*.py
instruments/interfaces.py → (none)
instruments/factory.py → interfaces.py, drivers/*.py
config/models.py → (none)
config/loader.py → models.py
app/config.py → (none)
data/models.py → (none)
data/repository.py → models.py
data/models.py → (none)
data/repository.py → models.py
executive/models.py → hal/interfaces.py
executive/*.py → models.py, data/repository.py
framework/context.py → instruments/interfaces.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 |
| 4 | `v0.1.0-alpha.2` | Visual demo (dashboard) | 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 |
| 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 |
| 7 | - | Instruments Done | SCPI simulators respond to commands |
| 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 |
| 17 | `v0.1.0` | **MVP Complete** | Full end-to-end workflow |

View File

@@ -40,9 +40,9 @@ dev = [
]
[project.scripts]
py-dvt-ate = "py_dvt_ate.cli.main:app"
py-dvt-ate-server = "py_dvt_ate.server.main:main"
py-dvt-ate-dashboard = "py_dvt_ate.dashboard.app:main"
py-dvt-ate = "py_dvt_ate.app.cli:app"
py-dvt-ate-server = "py_dvt_ate.simulation.server:main"
py-dvt-ate-dashboard = "py_dvt_ate.app.dashboard.app:main"
[build-system]
requires = ["hatchling"]

View File

@@ -1,3 +1,3 @@
"""py_dvt_ate: Coupled Physics DVT Simulation Platform."""
__version__ = "0.0.1"
__version__ = "0.1.0-alpha.1"

View File

@@ -0,0 +1,5 @@
"""Application entry points.
Contains CLI, dashboard, and configuration loading for the
py_dvt_ate application.
"""

View File

@@ -0,0 +1,5 @@
"""Streamlit dashboard for real-time monitoring.
Provides visualisation of instrument status, test progress,
and historical results.
"""

View File

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

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,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,5 @@
"""Physics simulation package.
Provides virtual instruments backed by a coupled thermal-electrical
physics engine. Used for development and testing without real hardware.
"""

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,5 @@
"""Virtual instrument implementations.
SCPI-compliant virtual instruments that respond like real hardware
but are backed by the physics simulation engine.
"""

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

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