128 Commits

Author SHA1 Message Date
885196d933 refactor(deploy): remove per-app tunnel in favour of central cloudflared
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 57s
CI / Release (push) Has been skipped
Routing now handled by central cloudflared + NPM on public-network.
2026-02-03 23:43:29 +00:00
f4c34e4689 fix(deploy): rename cloudflared service to avoid DNS collision
All checks were successful
CI / Lint (push) Successful in 6s
CI / Type Check (push) Successful in 21s
CI / Test (push) Successful in 1m0s
CI / Release (push) Has been skipped
Multiple projects using the same service name on a shared Docker network
causes DNS resolution conflicts. Renamed to 'tunnel' for uniqueness.
2026-02-03 00:15:13 +00:00
aba2cabbbc fix(dashboard): stop server event loop on correct thread
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 1m3s
CI / Release (push) Has been skipped
When idle shutdown triggered _stop_server(), it was creating a new event
loop and calling server.stop() on it, but the daemon thread was still
running loop.run_forever() on the original event loop. This left sockets
bound, causing "address already in use" on restart.

Fix by storing references to the server's event loop and thread, then
using call_soon_threadsafe(loop.stop) to signal the correct loop to exit.
The thread join ensures sockets are released before the next server starts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 00:16:47 +00:00
1ec05ea289 feat(dashboard): auto-stop server after 5 minutes idle
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 18s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped
Replace pause-on-idle with full server shutdown after IDLE_SHUTDOWN_SECONDS
(default 5 minutes). Next visitor gets a fresh simulation instance.

- Idle checker stops server and clears st.cache_resource
- init_session_state detects stopped server and recreates fresh state
- Clears instruments and history for clean restart

Configurable via IDLE_SHUTDOWN_SECONDS environment variable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:57:23 +00:00
b826337b36 fix(dashboard): use st.cache_resource for server singleton
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 19s
CI / Release (push) Has been skipped
CI / Test (push) Successful in 54s
Replace module-level singleton with @st.cache_resource decorator.
This properly survives Streamlit reruns without losing the server
reference, preventing "port already in use" errors when refreshing
the browser in Docker.

The cache is tied to the Streamlit process lifecycle, so when the
process restarts, both the cache and daemon threads are cleared
together.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:44:37 +00:00
235d668d9f fix(dashboard): handle orphan server on Docker refresh
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 23s
CI / Test (push) Successful in 53s
CI / Release (push) Has been skipped
Check port availability before singleton state to detect orphan servers
from previous processes. When ports are in use but singleton is None,
wait up to 5 seconds for the orphan to shut down before failing with a
clear error message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:38:31 +00:00
13f93b6739 fix(dashboard): improve server singleton robustness
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped
- Add reuse_address=True to TCP server start to allow quick rebind
  after process restart (TIME_WAIT state)
- Add _is_server_responsive() check to verify server is actually
  responding, not just trusting the is_running flag which can be stale
  if the server thread died unexpectedly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:26:56 +00:00
bc15df3051 fix(dashboard): use module-level singleton to prevent port conflicts on refresh
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped
When Streamlit refreshes/reruns, session state is lost but the old
simulation server thread keeps running on ports 5001-5003. This caused
"address already in use" errors when trying to start a new server.

Solution: Use a module-level singleton for the simulation server that
persists across Streamlit reruns. The get_or_create_server() function
checks if a server is already running before creating a new one.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:21:35 +00:00
66cdd4494c fix(docker): include README.md for pyproject.toml metadata
All checks were successful
CI / Test (push) Successful in 53s
CI / Release (push) Has been skipped
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
2026-01-29 22:38:52 +00:00
d1981a3342 fix(docker): use isolated public-network for security
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 54s
CI / Release (push) Has been skipped
2026-01-29 22:31:26 +00:00
45a2d9a6e5 fix(docker): use external apps-network for NPM integration
All checks were successful
CI / Test (push) Successful in 55s
CI / Release (push) Has been skipped
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 18s
2026-01-29 22:27:12 +00:00
51a479c61e fix(docker): use host nginx, fix libgdk-pixbuf package name
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 52s
CI / Release (push) Has been skipped
- Update libgdk-pixbuf2.0-0 to libgdk-pixbuf-2.0-0 for Debian Trixie
- Remove bundled nginx container in favor of host nginx
- Use host networking for cloudflared to reach host nginx
- Expose streamlit on localhost:8080 for host nginx proxy
2026-01-29 22:24:02 +00:00
a6ef649090 Fix CI: install reports dependencies for tests
All checks were successful
CI / Lint (push) Successful in 5s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 1m8s
CI / Release (push) Has been skipped
2026-01-29 22:05:11 +00:00
45a8f11650 Fix linter errors in CLI and tests
Some checks failed
CI / Lint (push) Successful in 6s
CI / Type Check (push) Successful in 20s
CI / Test (push) Failing after 37s
CI / Release (push) Has been skipped
2026-01-29 22:00:59 +00:00
7e8943ac57 Update deployment: remove Fly.io, add self-hosted Docker setup
Some checks failed
CI / Lint (push) Failing after 4s
CI / Type Check (push) Successful in 30s
CI / Release (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-01-29 21:57:27 +00:00
ddf2c9439d Simplify deployment - use physics pause instead of container shutdown
- IDLE_PAUSE_SECONDS replaces IDLE_TIMEOUT_MINUTES
- Container stays running, physics pauses when idle
- No restart mechanism needed
- Remove wakeup service (no longer needed)
2026-01-29 21:37:18 +00:00
9d6086a4e5 Auto-pause physics engine when no one is viewing
- Physics pauses after IDLE_PAUSE_SECONDS (default 30s) of inactivity
- Resumes instantly when someone views the dashboard
- No container restart needed - just pauses the simulation loop
- CPU usage drops to ~0% when paused
2026-01-29 21:36:38 +00:00
cc5a8191b0 Add idle auto-shutdown configuration to deployment
- IDLE_TIMEOUT_MINUTES env var (default 30 min)
- restart: no policy so container stays stopped
- Optional wakeup service for auto-restart
- Document three restart options in readme
2026-01-29 21:09:53 +00:00
b7663d5a31 Add idle auto-shutdown for self-hosted deployment
- IDLE_TIMEOUT_MINUTES env var to configure shutdown after inactivity
- Background thread monitors activity and exits when timeout reached
- Activity tracked via simulation_display fragment (runs while page open)
- Set to 0 (default) to disable auto-shutdown
2026-01-29 21:08:17 +00:00
6830b3158c Fix mypy type errors in reporting module 2026-01-29 18:06:13 +00:00
c016320b71 Update CHANGELOG with report generation feature 2026-01-29 18:03:39 +00:00
a5a2cf2473 Add report generation integration test 2026-01-29 18:03:23 +00:00
13a4fd16b3 Add reporting unit tests 2026-01-29 18:02:47 +00:00
349663b4e1 Add PDF download button to dashboard 2026-01-29 18:01:22 +00:00
2b92865745 Add export-report CLI command 2026-01-29 18:01:01 +00:00
022223af76 Add list-runs CLI command 2026-01-29 18:00:37 +00:00
bff13cd616 Add reporting configuration to default.yaml 2026-01-29 18:00:19 +00:00
59a5bc1124 Add ReportingConfig to application config 2026-01-29 18:00:04 +00:00
32daff69be Update reporting module public API 2026-01-29 17:59:45 +00:00
d76e610070 Implement ReportGenerator class 2026-01-29 17:59:27 +00:00
50432eaa3d Implement matplotlib chart generator 2026-01-29 17:59:01 +00:00
3b136dca69 Implement PDF renderer with WeasyPrint 2026-01-29 17:58:23 +00:00
5405ceec7f Implement HTML renderer with Jinja2 2026-01-29 17:58:02 +00:00
01d8295512 Add test report HTML template 2026-01-29 17:57:36 +00:00
3a8e6becf1 Add base HTML report template 2026-01-29 17:57:15 +00:00
af3116a025 Add professional CSS stylesheet for reports 2026-01-29 17:57:04 +00:00
f7f2839e65 Add reporting exception classes 2026-01-29 17:56:29 +00:00
5fdb1e6eaf Add report data models 2026-01-29 17:56:15 +00:00
ca7655704e Add matplotlib to reports dependencies 2026-01-29 17:55:55 +00:00
ba2ab9d5d8 Release v0.1.0
Some checks failed
CI / Release (push) Has been cancelled
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 19s
CI / Test (push) Successful in 37s
2025-12-04 13:18:17 +00:00
64be5dacbf Fix Windows file locking in repository tests 2025-11-16 20:13:43 +00:00
a28752fc5b Polish dashboard UX and update README
- Wrap simulation controls in form to prevent page reruns on change
- Fix TempCo test configs to use 2+ temperature points
- Add Installation, Quick Start, and usage examples to README
2025-11-15 13:18:38 +00:00
5152f85c8e Fix dashboard: connect instruments on startup, remove broken reset, apply controls properly 2025-11-09 15:56:06 +00:00
bd0071e88f Fix: auto-start charts and remove ScriptRunContext warning from background thread 2025-11-07 22:06:56 +00:00
400f97e9fb Fix server initialization race condition with proper event signaling 2025-11-06 09:52:35 +00:00
cae52c1fa8 Improve dashboard UX: connect instruments before tests, clarify simulation controls, show error messages 2025-11-01 19:51:41 +00:00
7c89cebf0b Fix dashboard database initialization using temp file instead of in-memory 2025-10-27 17:34:45 +00:00
5d185815d0 Add end-to-end integration test 2025-10-23 21:23:18 +00:00
9cf42112a6 Add results viewer dashboard page 2025-10-22 13:22:56 +00:00
ed5362e712 Add test execution dashboard page 2025-10-17 11:13:32 +00:00
d1170b7db7 Update dashboard to use HAL 2025-10-14 09:39:36 +00:00
42356efce2 Fix TempCo integration tests with thread-based async server
Redesign integration test architecture to eliminate async/sync deadlock:
- Run SimulationServer in dedicated background thread with own event loop
- Rewrite TempCo tests as fully synchronous (no @pytest.mark.asyncio)
- Add ServerThread fixture in tests/integration/conftest.py
- Fix Unicode encoding errors (replace deg, mu, +/- with ASCII)
- Optimize temperature points for faster settling (23C, 25C, 27C)

All 3 TempCo integration tests now passing in ~5 minutes total.
2025-10-12 17:59:48 +00:00
3fdaba500d Fix SCPI server response handling and add TEMP:RAMP command
- Revert server to only send responses for non-empty strings
  Per SCPI protocol: successful commands with no output send nothing
- Add TEMP:RAMP command support to thermal chamber simulator
- Fixes test_multiple_commands and test_physics_engine_integration

TCP server integration tests now passing (8/8).
TempCo integration tests still need work due to async/sync mixing.
2025-10-09 15:25:08 +00:00
a0d096512f WIP: Use thread pool executor for integration tests
Move synchronous test execution to thread pool executor to avoid
blocking the async event loop. This prevents deadlocks when sync
client code tries to communicate with async server in same loop.

Note: Integration tests still experiencing timeouts - needs further
investigation. Unit tests and TCP server communication are working.
2025-10-08 16:16:13 +00:00
1f42098b6e Fix TCP server response handling and add pandas-stubs
- Always send a response (even empty) to prevent client timeouts
- Add pandas-stubs to dev dependencies for mypy type checking
- Server now sends newline-terminated response for all commands

This fixes the mypy CI failure. Integration test failures still need
investigation - likely due to event loop blocking when mixing sync/async.
2025-10-01 17:37:36 +00:00
7093446783 Fix CI errors: linting, type checking, and tests
- Fix import sorting in test_instruments.py (ruff I001)
- Install pandas-stubs for mypy type checking
- Add garbage collection cleanup to repository test fixtures
- Prevent Windows file locking errors in tempfile cleanup

All CI checks now passing: lint, type check, and all 244 tests.
2025-09-29 18:02:39 +00:00
22be547e47 Add instrument CLI commands 2025-09-26 17:56:36 +00:00
825af0b3bd Add test execution CLI commands 2025-09-22 13:32:05 +00:00
2e62a10550 Release v0.1.0-beta.2
Some checks failed
CI / Lint (push) Failing after 4s
CI / Type Check (push) Successful in 18s
CI / Test (push) Failing after 20s
CI / Release (push) Has been cancelled
2025-09-22 11:04:43 +00:00
d07e6e3f1a Add TempCo integration tests 2025-09-16 11:20:03 +00:00
96eb83cec4 Implement TempCo characterisation test 2025-09-15 18:32:58 +00:00
027fd71505 Add DVT test base class 2025-09-10 16:03:37 +00:00
3310e86fae Implement test runner 2025-09-09 19:31:09 +00:00
e42de212f2 Implement limit checker 2025-09-05 15:44:46 +00:00
ee8d148eb7 Implement test logger 2025-08-31 17:24:16 +00:00
e379b7e432 Add test framework models 2025-08-28 21:57:03 +00:00
eaa1843ee1 Add data persistence tests 2025-08-25 14:31:30 +00:00
7429f6433c Add Parquet measurement storage 2025-08-20 23:59:48 +00:00
7cfd36f02b Implement SQLite repository 2025-08-17 20:54:35 +00:00
f5600efd76 Add data persistence models 2025-08-17 11:34:53 +00:00
0615eb7e07 Add configuration tests 2025-08-14 15:59:04 +00:00
b981182b71 Add default configuration file 2025-08-11 13:11:17 +00:00
8c0d68e722 Implement configuration loader 2025-08-05 15:12:34 +00:00
4e14222522 Add configuration Pydantic models 2025-08-01 17:46:03 +00:00
afa52e7ee2 Release v0.1.0-beta.1
Some checks failed
CI / Lint (push) Failing after 4s
CI / Type Check (push) Successful in 18s
CI / Test (push) Successful in 10s
CI / Release (push) Has been cancelled
2025-08-01 11:46:21 +00:00
a951413a62 Add instrument interface tests 2025-07-26 20:12:06 +00:00
0b58f7e863 Add instrument factory 2025-07-26 19:22:27 +00:00
a8bd132269 Implement instrument interfaces in drivers 2025-07-24 18:15:50 +00:00
0a8d7e5c69 Add instrument interface protocols 2025-07-19 14:08:46 +00:00
ece1803c10 Fix linting: use builtin OSError and TimeoutError instead of socket aliases 2025-07-15 11:15:17 +00:00
76d81b21e6 Add driver unit tests 2025-07-10 15:57:02 +00:00
4db50421b3 Add PSU and DMM drivers 2025-07-08 09:45:40 +00:00
10e1da198e Add thermal chamber driver 2025-07-04 18:14:37 +00:00
8fe97047d1 Add driver base class 2025-06-30 15:04:21 +00:00
1f00210b63 Refactor DUTModel from Protocol to ABC for explicit interface implementation 2025-06-29 17:33:28 +00:00
95961cd26f Refactor Transport from Protocol to ABC for explicit interface implementation 2025-06-25 19:25:42 +00:00
fe208b0c04 Update specification to mandate ABC over Protocol for maximum type safety 2025-06-24 23:59:34 +00:00
d38c40d52d Add transport layer tests 2025-06-19 15:55:38 +00:00
936ed5a279 Implement TCP transport 2025-06-16 13:30:35 +00:00
284793df69 Add transport protocol definition 2025-06-14 20:48:34 +00:00
e38f514153 Fix linting and type errors for CI
- Use X | None syntax instead of Optional[X] (UP045)
- Sort imports in dashboard app (I001)
- Remove unnecessary UTF-8 encoding argument (UP012)
- Add 'from err' to exception re-raises (B904)
- Remove unused imports in integration tests (F401)
- Fix useless expression in test (B018)
- Cast **1.5 result to float in LDO model (mypy no-any-return)
- Use functools.partial instead of lambda in server (mypy misc)
2025-06-12 22:05:46 +00:00
cfe8dab7a8 Move InstrumentServer to instruments/transport
InstrumentServer is a general-purpose SCPI-over-TCP server that can
host any device implementing the SCPIDevice protocol (process method).
Moving it from simulation/ to instruments/transport/ reflects this:
- simulation package now depends on instruments package
- InstrumentServer can host both virtual and real instrument adapters
- Added SCPIDevice Protocol for type-safe device registration
2025-06-07 15:15:56 +00:00
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
110 changed files with 15160 additions and 513 deletions

50
.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Git
.git
.gitignore
.gitea
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info
.eggs
dist
build
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov
# Tests
tests/
pytest.ini
# Documentation
docs/
*.md
!README.md
# Development
.vscode
.idea
*.swp
*.swo
*~
# Environment
.env
.env.*
venv
.venv
# Data (will be created fresh in container)
data/
*.db
*.parquet
# CI/CD
.gitea/

View File

@@ -57,10 +57,15 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -e ".[dev]" pip install -e ".[dev,reports]"
- name: Run pytest - name: Run pytest
run: pytest --cov=src/py_dvt_ate --cov-report=xml run: pytest --cov=src/py_dvt_ate --cov-report=xml
@@ -72,7 +77,7 @@ jobs:
release: release:
name: Release name: Release
needs: [lint, typecheck, test] needs: [lint, typecheck, test]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -91,7 +96,22 @@ jobs:
run: python -m build run: python -m build
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 run: |
with: TAG_NAME=${GITHUB_REF#refs/tags/}
files: dist/* VERSION=${TAG_NAME#v}
generate_release_notes: true BODY=$(awk "/^## \[${VERSION}\]/{flag=1; next} /^## \\[/{flag=0} flag" CHANGELOG.md)
echo "Creating release ${TAG_NAME}"
RESPONSE=$(curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/json" -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"${TAG_NAME}\", \"body\": $(echo "$BODY" | jq -Rs .)}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases")
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
echo "Created release ID: ${RELEASE_ID}"
if [ "$RELEASE_ID" != "null" ] && [ -n "$RELEASE_ID" ]; then
for file in dist/*; do
echo "Uploading $(basename ${file})..."
curl -s -X POST -H "Authorization: token ${GITHUB_TOKEN}" -F "attachment=@${file}" "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=$(basename ${file})"
done
else
echo "Failed to create release: $RESPONSE"
exit 1
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -56,3 +56,4 @@ logs/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
CLAUDE.md

View File

@@ -7,6 +7,159 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Docker deployment configuration for public demo
- Dockerfile with Python 3.11-slim and WeasyPrint dependencies
- .dockerignore to exclude tests, docs, and development files
- deploy/ directory with docker-compose, nginx config, and Cloudflare Tunnel setup
- PDF Report Generation (Sprint 18)
- Professional PDF reports from test results with charts and styling
- `ReportGenerator` class orchestrating data gathering, chart generation, and PDF output
- `ReportConfig` for customising company name, logo, charts, and DPI
- HTML templates with Jinja2 for report structure
- CSS stylesheet optimised for A4 PDF output via WeasyPrint
- Matplotlib-based chart generation (voltage vs temperature, results summary)
- New CLI commands:
- `list-runs`: Display recent test runs with IDs
- `export-report`: Generate PDF report from run ID
- Dashboard PDF download button in Results Viewer
- Reporting configuration section in default.yaml
- Unit tests for models, HTML renderer, and chart generator
- Integration test for full report generation pipeline
### Changed
- Added `matplotlib>=3.8` to reports optional dependencies
## [0.1.0] - 2025-12-04
### Added
- Streamlit Dashboard Enhancement (Sprint 17)
- HAL-based instrument control (no direct physics access)
- Test execution page for running TempCo characterisation
- Results viewer page with filtering and historical data
- Form-based parameter controls preventing UI clunkiness
- Live simulation charts with auto-start
- End-to-end integration tests covering full workflow
- Updated README with installation and usage instructions
- Proprietary licence
### Changed
- Integration tests now run 100x faster with simulation time scaling
- Removed confusing pause/clear chart buttons from dashboard
### Fixed
- CI release workflow now creates proper releases with changelog description
### Technical
- Dashboard uses InstrumentFactory and InstrumentSet abstraction
- Embedded SimulationServer with threading synchronisation
- SQLite repository close() method for Windows file handle cleanup
- 259 unit tests, 12 integration tests all passing
- Coverage: 100% on core physics/instrument modules
## [0.1.0-beta.2] - 2025-12-03
### Added
- Test Executive Framework (Sprint 14)
- TestContext dataclass providing runtime context for tests
- ITest abstract base class defining test interface
- TestLogger for recording measurements, results, and events
- LimitChecker for evaluating pass/fail against specification limits
- TestRunner for orchestrating test execution
- SQLite-based TestRepository for persisting test data
- Parquet measurement storage for efficient time-series data
- DVT Test Implementation (Sprint 15)
- BaseDVTTest providing common test utilities
- TempCo characterisation test (temperature coefficient measurement)
- Temperature sweep with automatic thermal settling
- Linear regression TempCo calculation (ppm/°C)
- Comprehensive integration tests for end-to-end validation
### Technical
- Test framework supports data logging, limit evaluation, and result persistence
- TempCo test demonstrates full end-to-end workflow: configure instruments → sweep temperature → measure → calculate → evaluate
- All framework and test components fully type-checked and linted
## [0.1.0-beta.1] - 2025-12-02
### Added
- Hardware Abstraction Layer (HAL) with instrument interface protocols
- IThermalChamber protocol with temperature control methods
- IPowerSupply protocol with voltage/current control and measurement
- IMultimeter protocol with DC voltage, current, and resistance measurement
- Instrument drivers implementing HAL interfaces
- ThermalChamberDriver implements IThermalChamber
- PowerSupplyDriver implements IPowerSupply
- MultimeterDriver implements IMultimeter
- Instrument factory pattern for backend abstraction
- InstrumentSet dataclass containing chamber, PSU, and DMM
- InstrumentConfig for specifying backend (simulator/pyvisa) and connection details
- InstrumentFactory.create() for creating instrument sets from configuration
- Transport layer abstraction
- Transport ABC defining connect/disconnect/read/write/query interface
- TCPTransport implementation for TCP/IP connections
- Comprehensive test suite for HAL (16 tests)
- Interface implementation verification
- Factory pattern testing with mocked backends
- Configuration validation
### Changed
- Drivers now explicitly inherit from interface ABCs for maximum type safety
- Moved InstrumentServer to instruments/transport for better architecture
### Technical
- ABC-based interfaces ensure compile-time interface compliance
- Factory pattern enables seamless switching between simulated and real hardware
- All HAL components fully type-checked with mypy strict mode
## [0.1.0-alpha.3] - 2025-12-02
### Added
- Async TCP server for exposing virtual instruments over network
- InstrumentServer class with multi-port, multi-client support
- Line-based SCPI protocol (newline-terminated commands/responses)
- SimulationServer wiring physics engine to all virtual instruments
- CLI `serve` command to start simulation server with configurable ports
- Integration tests for TCP server and instrument connectivity
### Infrastructure
- SCPI foundation (Sprint 5): command parser with IEEE 488.2 support
- Virtual instrument base class with command dispatch
- Thermal chamber simulator (TEMP:SETPOINT, TEMP:ACTUAL?, TEMP:STAB?)
- Power supply simulator (VOLT, CURR, OUTP, MEAS commands)
- Multimeter simulator (MEAS:VOLT:DC?, MEAS:CURR:DC?, CONF, READ?)
## [0.1.0-alpha.2] - 2025-12-02
### 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
@@ -25,10 +178,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| Version | Date | Milestone | | Version | Date | Milestone |
|---------|------|-----------| |---------|------|-----------|
| 0.1.0 | TBD | MVP Complete | | 0.1.0 | 2025-12-04 | MVP Complete |
| 0.1.0-beta.2 | TBD | First DVT test runs | | 0.1.0-beta.2 | 2025-12-03 | First DVT test runs |
| 0.1.0-beta.1 | TBD | HAL complete | | 0.1.0-beta.1 | 2025-12-02 | HAL complete |
| 0.1.0-alpha.3 | TBD | Network ready | | 0.1.0-alpha.3 | 2025-12-02 | Network ready |
| 0.1.0-alpha.2 | 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 |

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for WeasyPrint (PDF generation)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# Copy project files
COPY pyproject.toml README.md ./
COPY src/ src/
COPY config/ config/
# Install package with reports dependencies (for PDF export)
RUN pip install --no-cache-dir -e ".[reports]"
# Create data directory for SQLite and measurements
RUN mkdir -p /app/data/measurements /app/data/reports
# Streamlit configuration
RUN mkdir -p ~/.streamlit
RUN echo '[server]\n\
headless = true\n\
address = "0.0.0.0"\n\
port = 8080\n\
enableXsrfProtection = false\n\
enableCORS = false\n\
\n\
[browser]\n\
gatherUsageStats = false\n' > ~/.streamlit/config.toml
EXPOSE 8080
CMD ["streamlit", "run", "src/py_dvt_ate/app/dashboard/app.py"]

5
LICENSE Normal file
View File

@@ -0,0 +1,5 @@
Copyright (c) 2025 Kai Chappell. All rights reserved.
This software is proprietary and confidential. Unauthorized copying,
distribution, modification, or use of this software, via any medium,
is strictly prohibited without prior written permission from the author.

View File

@@ -1,12 +1,12 @@
# py_dvt_ate # py_dvt_ate
**ThermalATE: Coupled Physics DVT Simulation Platform** **Coupled Physics DVT Simulation Platform**
A software simulation environment that accurately models the physical coupling between thermal and electrical domains, enabling DVT (Design Validation Test) engineers to develop, validate, and debug characterisation test sequences without physical access to laboratory equipment. A software simulation environment for offline development of ATE (Automated Test Equipment) characterisation algorithms. Accurately models thermal-electrical coupling, enabling DVT engineers to develop and validate test sequences without physical laboratory access.
## Overview ## Overview
ThermalATE enables offline development of ATE (Automated Test Equipment) characterisation algorithms by simulating: py_dvt_ate simulates a complete DVT test bench:
- **Thermal Chamber** - Temperature control with realistic ramp and settling behaviour - **Thermal Chamber** - Temperature control with realistic ramp and settling behaviour
- **Programmable Power Supply** - Voltage/current control and measurement - **Programmable Power Supply** - Voltage/current control and measurement
@@ -29,11 +29,78 @@ ThermalATE enables offline development of ATE (Automated Test Equipment) charact
| [Technical Specification](docs/02_technical_specification.md) | Specifies **how** to implement the system | | [Technical Specification](docs/02_technical_specification.md) | Specifies **how** to implement the system |
| [Architecture Decisions](docs/03_architecture_decisions.md) | Explains **why** decisions were made | | [Architecture Decisions](docs/03_architecture_decisions.md) | Explains **why** decisions were made |
## Installation
```bash
# Install with development dependencies
pip install -e ".[dev]"
```
## Quick Start
### Interactive Dashboard
Launch the Streamlit dashboard to visualise the physics simulation and run tests:
```bash
py-dvt-ate dashboard
```
This opens a browser window with:
- **Live Simulation** - Real-time temperature/voltage charts with physics coupling
- **Test Execution** - Run TempCo characterisation tests
- **Results Viewer** - Browse and analyse historical test results
### CLI Commands
```bash
# Start the simulation server (TCP ports for SCPI instruments)
py-dvt-ate serve
# List available tests
py-dvt-ate tests list
# Run a TempCo test
py-dvt-ate tests run tempco --config config/tempco_test.yaml
```
### Programmatic API
```python
from py_dvt_ate.instruments import InstrumentFactory
from py_dvt_ate.simulation import SimulationServer
# Start simulation server
server = SimulationServer()
server.start()
# Create instruments via HAL
factory = InstrumentFactory()
instruments = factory.create_from_config("config/default.yaml")
# Control instruments using standard interfaces
instruments.chamber.set_temperature(85.0)
instruments.psu.set_voltage(1, 5.0)
instruments.psu.enable_output(1, True)
voltage = instruments.dmm.measure_dc_voltage()
print(f"Output voltage: {voltage:.4f} V")
```
## Project Status ## Project Status
**Status:** In Development **Status:** MVP Complete (v0.1.0)
This project is currently being developed. See the requirements document for the full scope and success criteria. The core vertical slice is functional:
- Physics engine with thermal-electrical coupling
- Virtual instruments (chamber, PSU, DMM)
- Hardware Abstraction Layer
- SCPI-over-TCP server
- Test framework with TempCo test
- Streamlit dashboard
- SQLite/Parquet data persistence
See the requirements document for the full scope and future phases.
## Technology Stack ## Technology Stack
@@ -50,4 +117,4 @@ Kai Chappell
## Licence ## Licence
TBD Proprietary - All rights reserved. See [LICENSE](LICENSE) for details.

172
config/default.yaml Normal file
View File

@@ -0,0 +1,172 @@
# py_dvt_ate Default Configuration
# This file contains default settings for the DVT simulation platform.
# Copy this file and modify values as needed for your environment.
# =============================================================================
# Instrument Configuration
# =============================================================================
instruments:
# Backend selection: "simulator" or "pyvisa"
# - simulator: Use virtual instruments with physics simulation (for development)
# - pyvisa: Connect to real instruments via PyVISA (for production testing)
backend: simulator
# Simulator backend configuration
# Used when backend=simulator. Virtual instruments are exposed as TCP servers.
simulator:
host: localhost
thermal_chamber_port: 5001
power_supply_port: 5002
multimeter_port: 5003
# PyVISA backend configuration
# Used when backend=pyvisa. Provide VISA resource strings for real instruments.
# Example: "TCPIP::192.168.1.10::5001::SOCKET"
pyvisa:
thermal_chamber: null
power_supply: null
multimeter: null
# =============================================================================
# Physics Simulation Parameters
# =============================================================================
physics:
# Physics engine update rate (Hz)
# Higher rates provide better accuracy but use more CPU.
update_rate_hz: 100.0
# Thermal model parameters
thermal:
# Chamber thermal time constant (seconds)
# Time for chamber temperature to reach 63% of final value
chamber_time_constant_s: 30.0
# DUT case thermal time constant (seconds)
# Time for case temperature to reach 63% of final value
case_time_constant_s: 5.0
# Junction-to-case thermal resistance (°C/W)
# How much the junction heats above case per watt dissipated
theta_jc: 15.0
# Case-to-ambient thermal resistance (°C/W)
# How much the case heats above ambient per watt dissipated
theta_ca: 5.0
# Thermal chamber behaviour
chamber:
# Maximum temperature ramp rate (°C/min)
# Real chambers have limited heating/cooling rates
ramp_rate_c_per_min: 10.0
# Temperature stability window (°C)
# Chamber is considered stable when within ±this value of setpoint
stability_window_c: 0.5
# Stability duration requirement (seconds)
# Chamber must remain in stability window for this duration
stability_time_s: 30.0
# =============================================================================
# DUT (Device Under Test) Configuration
# =============================================================================
dut:
# DUT model type
# Currently supported: "ldo"
model: ldo
# DUT model parameters
parameters:
# Nominal output voltage at 25°C (V)
nominal_output_voltage: 3.3
# Temperature coefficient (ppm/°C)
# Voltage change per degree: ΔV = V₀ × tempco × ΔT / 1e6
tempco_ppm_per_c: 50.0
# Quiescent current at 25°C (µA)
quiescent_current_ua: 50.0
# Quiescent current temperature coefficient (per °C)
# Iq change per degree: ΔIq = Iq₀ × tempco × ΔT
quiescent_current_tempco: 0.003
# Dropout voltage (V)
# Minimum Vin-Vout differential for regulation
dropout_voltage: 0.3
# =============================================================================
# Data Storage Configuration
# =============================================================================
data:
# SQLite database path for test runs and results
database_path: ./data/py_dvt_ate.db
# Directory for measurement data files (Parquet format)
measurements_dir: ./data/measurements
# Directory for generated reports (PDF, HTML)
reports_dir: ./data/reports
# =============================================================================
# Logging Configuration
# =============================================================================
logging:
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
level: INFO
# Log file path
# Use null to disable file logging
file: ./data/logs/py_dvt_ate.log
# Log message format
# Uses Python logging format strings
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# =============================================================================
# Dashboard Configuration (Streamlit)
# =============================================================================
dashboard:
# Enable/disable the Streamlit dashboard
enabled: true
# Dashboard server port
port: 8501
# =============================================================================
# API Configuration (Phase 2)
# =============================================================================
api:
# Enable/disable the REST API server
# Currently not implemented (Phase 2 feature)
enabled: false
# API server host
# Use "0.0.0.0" to listen on all interfaces
host: "0.0.0.0"
# API server port
port: 8000
# =============================================================================
# Report Generation Configuration
# =============================================================================
reporting:
# Company name displayed in report header
# This appears in the title block and footer of generated reports
company_name: py_dvt_ate
# Path to company logo image file (PNG or JPEG)
# If null, no logo is displayed in report header
# Example: ./assets/logo.png
logo_path: null
# Include charts in generated reports
# Charts show voltage vs temperature and results summary
# Set to false for text-only reports (smaller file size)
include_charts: true
# DPI (dots per inch) for chart images
# Higher values produce sharper charts but larger file sizes
# Recommended: 150 for screen viewing, 300 for print quality
chart_dpi: 150

8
deploy/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Cloudflare Tunnel token
# Get this from: Cloudflare Zero Trust > Networks > Tunnels > Create > Docker
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token-here
# Idle auto-pause (seconds)
# Physics engine pauses after this many seconds with no viewers
# CPU drops to ~0% when paused, resumes instantly on visit
IDLE_PAUSE_SECONDS=30

19
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
streamlit:
build:
context: ..
dockerfile: Dockerfile
container_name: py-dvt-ate-streamlit
restart: unless-stopped
environment:
- IDLE_PAUSE_SECONDS=${IDLE_PAUSE_SECONDS:-30} # Pause physics after 30s idle
expose:
- "8080"
volumes:
- ./data:/app/data
networks:
- public-network
networks:
public-network:
external: true

45
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
events {
worker_connections 1024;
}
http {
upstream streamlit {
server streamlit:8080;
}
server {
listen 80;
server_name _;
# Remove X-Frame-Options to allow iframe embedding
proxy_hide_header X-Frame-Options;
# Allow embedding from your domain
add_header Content-Security-Policy "frame-ancestors 'self' https://kschappell.com https://*.kschappell.com";
location / {
proxy_pass http://streamlit;
proxy_http_version 1.1;
# WebSocket support (required for Streamlit)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for long-running connections
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# Health check endpoint
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}
}

169
deploy/readme.md Normal file
View File

@@ -0,0 +1,169 @@
# py-dvt-ate Deployment
Deploy the DVT Simulation Platform dashboard with Cloudflare Tunnel for public access.
## Idle Auto-Pause
The physics simulation automatically pauses when no one is viewing the dashboard.
**Configuration:**
```bash
# In .env or docker-compose.yml
IDLE_PAUSE_SECONDS=30 # Pause physics after 30s idle
```
**Behaviour:**
- When someone views the dashboard, physics runs normally
- After `IDLE_PAUSE_SECONDS` with no viewers, physics engine pauses
- CPU drops to ~0% while paused
- Physics resumes instantly when someone visits
- Container stays running (no restart needed)
## Architecture
```
Internet
Cloudflare Edge (dvt-demo.kschappell.com)
↓ (tunnel)
cloudflared container
nginx container (WebSocket proxy + header handling)
streamlit container (port 8080)
```
## Directory Structure
```
/mnt/fast-pool/apps/portfolio-demos/py-dvt-ate/
├── docker-compose.yml
├── nginx.conf
├── .env
└── data/ # Persistent storage (created automatically)
├── py_dvt_ate.db # SQLite database
├── measurements/ # Test measurement files
└── reports/ # Generated PDFs
```
## Setup
### 1. Create Cloudflare Tunnel
1. Go to [Cloudflare Zero Trust Dashboard](https://one.dash.cloudflare.com/)
2. Navigate to **Networks****Tunnels**
3. Click **Create a tunnel**
4. Select **Cloudflared** as the connector
5. Name it `py-dvt-ate` (or similar)
6. Copy the tunnel token (long string starting with `eyJ...`)
### 2. Configure Public Hostname
Still in the tunnel configuration:
1. Go to the **Public Hostname** tab
2. Add a public hostname:
- **Subdomain:** `dvt-demo`
- **Domain:** `kschappell.com`
- **Service Type:** `HTTP`
- **URL:** `nginx:80`
This routes `dvt-demo.kschappell.com` → nginx container → Streamlit app.
### 3. Deploy
The build requires access to the full py-dvt-ate source code. Two options:
**Option A: Clone repo to TrueNAS (recommended)**
```bash
# Clone repo to apps directory
cd /mnt/fast-pool/apps/portfolio-demos
git clone https://gitea.kschappell.com/kschappell/py-dvt-ate.git
# Create data directory and .env
cd py-dvt-ate/deploy
mkdir -p data
cp .env.example .env
nano .env # Add your CLOUDFLARE_TUNNEL_TOKEN
# Build and start
docker compose up -d --build
```
**Option B: Build image locally, transfer to TrueNAS**
```bash
# On development machine
cd /path/to/py-dvt-ate
docker build -t py-dvt-ate:latest .
docker save py-dvt-ate:latest | gzip > py-dvt-ate.tar.gz
# Transfer to TrueNAS, then:
docker load < py-dvt-ate.tar.gz
# Update docker-compose.yml to use image instead of build:
# streamlit:
# image: py-dvt-ate:latest
```
**Check logs:**
```bash
docker compose logs -f
```
### 4. Verify
```bash
# Check tunnel is connected (in Cloudflare dashboard, tunnel should show "Healthy")
# Test the endpoint
curl https://dvt-demo.kschappell.com/health
# Test iframe headers
curl -I https://dvt-demo.kschappell.com | grep -i frame
# Should NOT show X-Frame-Options (we strip it)
```
## Troubleshooting
### Tunnel not connecting
Check cloudflared logs:
```bash
docker compose logs cloudflared
```
Common issues:
- Invalid token (regenerate in Cloudflare dashboard)
- Network/firewall blocking outbound connections
### Streamlit not loading in iframe
Check nginx is stripping headers:
```bash
curl -I https://dvt-demo.kschappell.com
```
Should see:
- No `X-Frame-Options` header
- `Content-Security-Policy: frame-ancestors 'self' https://kschappell.com ...`
### WebSocket errors
Check browser console for WebSocket connection failures. Ensure nginx WebSocket config is correct and timeouts are sufficient.
## Updating
```bash
cd /mnt/fast-pool/apps/portfolio-demos/py-dvt-ate/deploy
git pull
docker compose up -d --build
```
## Stopping
```bash
docker compose down
```

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
│ ├── 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 │ ├── __init__.py
│ ├── py.typed # PEP 561 marker │ ├── cli.py # Command-line interface
├── config.py # YAML loading
── physics/ # Physics simulation engine ── dashboard/ # Streamlit dashboard
│ │ ├── __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
│ ├── __init__.py │ ├── __init__.py
── models.py # Pydantic config models ── app.py # Main Streamlit app
│ └── loader.py # Config file loader
├── 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,212 +387,186 @@ 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 abc import ABC, abstractmethod
@runtime_checkable @runtime_checkable
class IThermalChamber(Protocol): class IThermalChamber(ABC):
"""Hardware abstraction for thermal chambers.""" """Hardware abstraction for thermal chambers."""
@abstractmethod
def set_temperature(self, setpoint: float) -> None: def set_temperature(self, setpoint: float) -> None:
"""Set target temperature in degrees Celsius.""" """[docstring]"""
... pass
@abstractmethod
def get_temperature(self) -> float: def get_temperature(self) -> float:
"""Get current actual temperature in degrees Celsius.""" """[docstring]"""
... pass
@abstractmethod
def get_setpoint(self) -> float: def get_setpoint(self) -> float:
"""Get current temperature setpoint.""" """[docstring]"""
... pass
@abstractmethod
def is_stable(self) -> bool: def is_stable(self) -> bool:
"""Check if temperature has stabilised at setpoint.""" """[docstring]"""
... pass
@abstractmethod
def wait_until_stable( def wait_until_stable(
self, self,
timeout: float = 300.0, timeout: float = 300.0,
poll_interval: float = 1.0 poll_interval: float = 1.0
) -> bool: ) -> bool:
""" """[docstring]"""
Block until temperature stabilises or timeout. pass
Returns:
True if stable, False if timeout
"""
...
@abstractmethod
def set_ramp_rate(self, rate: float) -> None: def set_ramp_rate(self, rate: float) -> None:
"""Set temperature ramp rate in degrees C per minute.""" """[docstring]"""
... pass
@runtime_checkable @runtime_checkable
class IPowerSupply(Protocol): class IPowerSupply(ABC):
"""Hardware abstraction for programmable power supplies.""" """Hardware abstraction for programmable power supplies."""
@abstractmethod
def set_voltage(self, channel: int, voltage: float) -> None: def set_voltage(self, channel: int, voltage: float) -> None:
"""Set output voltage for specified channel.""" """[docstring]"""
... pass
@abstractmethod
def get_voltage(self, channel: int) -> float: def get_voltage(self, channel: int) -> float:
"""Get voltage setpoint for specified channel.""" """[docstring]"""
... pass
@abstractmethod
def set_current_limit(self, channel: int, current: float) -> None: def set_current_limit(self, channel: int, current: float) -> None:
"""Set current limit for specified channel.""" """[docstring]"""
... pass
@abstractmethod
def get_current_limit(self, channel: int) -> float: def get_current_limit(self, channel: int) -> float:
"""Get current limit for specified channel.""" """[docstring]"""
... pass
@abstractmethod
def measure_voltage(self, channel: int) -> float: def measure_voltage(self, channel: int) -> float:
"""Measure actual output voltage.""" """[docstring]"""
... pass
@abstractmethod
def measure_current(self, channel: int) -> float: def measure_current(self, channel: int) -> float:
"""Measure actual output current.""" """[docstring]"""
... pass
@abstractmethod
def enable_output(self, channel: int, enable: bool) -> None: def enable_output(self, channel: int, enable: bool) -> None:
"""Enable or disable channel output.""" """[docstring]"""
... pass
@abstractmethod
def is_output_enabled(self, channel: int) -> bool: def is_output_enabled(self, channel: int) -> bool:
"""Check if channel output is enabled.""" """[docstring]"""
... pass
@runtime_checkable @runtime_checkable
class IMultimeter(Protocol): class IMultimeter(ABC):
"""Hardware abstraction for digital multimeters.""" """Hardware abstraction for digital multimeters."""
@abstractmethod
def measure_dc_voltage(self, range: str = "AUTO") -> float: def measure_dc_voltage(self, range: str = "AUTO") -> float:
"""Measure DC voltage. Range: AUTO, 0.1, 1, 10, 100, 1000.""" """[docstring]"""
... pass
@abstractmethod
def measure_dc_current(self, range: str = "AUTO") -> float: def measure_dc_current(self, range: str = "AUTO") -> float:
"""Measure DC current.""" """[docstring]"""
... pass
@abstractmethod
def measure_resistance(self, range: str = "AUTO") -> float: def measure_resistance(self, range: str = "AUTO") -> float:
"""Measure resistance.""" """[docstring]"""
... pass
@abstractmethod
def set_integration_time(self, nplc: float) -> None: def set_integration_time(self, nplc: float) -> None:
"""Set integration time in power line cycles (0.1 to 100).""" """[docstring]"""
... pass
@runtime_checkable @runtime_checkable
class ITestLogger(Protocol): class ITestLogger(ABC):
"""Abstraction for test data logging.""" """Abstraction for test data logging."""
@abstractmethod
def log_measurement( def log_measurement(
self, self,
parameter: str, parameter: str,
@@ -642,9 +574,10 @@ class ITestLogger(Protocol):
unit: str, unit: str,
conditions: dict[str, float] | None = None conditions: dict[str, float] | None = None
) -> None: ) -> None:
"""Log a single measurement.""" """[docstring]"""
... pass
@abstractmethod
def log_result( def log_result(
self, self,
parameter: str, parameter: str,
@@ -653,60 +586,67 @@ class ITestLogger(Protocol):
lower_limit: float | None = None, lower_limit: float | None = None,
upper_limit: float | None = None upper_limit: float | None = None
) -> None: ) -> None:
"""Log a test result with optional limits.""" """[docstring]"""
... pass
@abstractmethod
def log_event(self, message: str, level: str = "INFO") -> None: def log_event(self, message: str, level: str = "INFO") -> None:
"""Log a test event or message.""" """[docstring]"""
... pass
``` ```
### 4.2 Transport Interface ### 4.2 Transport Interface
```python ```python
# thermaulate/transport/base.py # py_dvt_ate/instruments/transport/base.py
from typing import Protocol from abc import ABC, abstractmethod
class Transport(Protocol): class Transport(ABC):
"""Abstract transport interface for instrument communication.""" """Abstract transport interface for instrument communication."""
@abstractmethod
def connect(self) -> None: def connect(self) -> None:
"""Establish connection to instrument.""" """[docstring]"""
... pass
@abstractmethod
def disconnect(self) -> None: def disconnect(self) -> None:
"""Close connection to instrument.""" """[docstring]"""
... pass
@abstractmethod
def write(self, command: str) -> None: def write(self, command: str) -> None:
"""Send command to instrument.""" """[docstring]"""
... pass
@abstractmethod
def read(self, timeout: float | None = None) -> str: def read(self, timeout: float | None = None) -> str:
"""Read response from instrument.""" """[docstring]"""
... pass
@abstractmethod
def query(self, command: str, timeout: float | None = None) -> str: def query(self, command: str, timeout: float | None = None) -> str:
"""Send command and read response.""" """[docstring]"""
... pass
@property @property
@abstractmethod
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Check if connection is active.""" """[docstring]"""
... pass
``` ```
### 4.3 Test Interface ### 4.3 Test Interface
```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
from enum import Enum from enum import Enum
from typing import Protocol from abc import ABC, abstractmethod
from uuid import UUID from uuid import UUID
@@ -757,33 +697,36 @@ class TestContext:
config: dict config: dict
class ITest(Protocol): class ITest(ABC):
"""Interface for test implementations.""" """Interface for test implementations."""
@property @property
@abstractmethod
def name(self) -> str: def name(self) -> str:
"""Test name identifier.""" """[docstring]"""
... pass
@property @property
@abstractmethod
def description(self) -> str: def description(self) -> str:
"""Human-readable test description.""" """[docstring]"""
... pass
@abstractmethod
def execute(self, context: TestContext) -> TestStatus: def execute(self, context: TestContext) -> TestStatus:
"""Execute the test, return status.""" """[docstring]"""
... pass
``` ```
### 4.4 Factory Interface ### 4.4 Factory Interface
```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 +770,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 +894,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 +1011,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,32 +1196,36 @@ 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 abc import ABC, abstractmethod
from uuid import UUID from uuid import UUID
class ITestRepository(Protocol): class ITestRepository(ABC):
"""Repository interface for test data.""" """Repository interface for test data."""
@abstractmethod
def create_run( def create_run(
self, self,
test_name: str, test_name: str,
config: dict, config: dict,
operator: str | None = None operator: str | None = None
) -> UUID: ) -> UUID:
"""Create a new test run, return its ID.""" """[docstring]"""
... pass
@abstractmethod
def update_run_status(self, run_id: UUID, status: str) -> None: def update_run_status(self, run_id: UUID, status: str) -> None:
"""Update test run status.""" """[docstring]"""
... pass
@abstractmethod
def complete_run(self, run_id: UUID, status: str) -> None: def complete_run(self, run_id: UUID, status: str) -> None:
"""Mark test run as complete with final status.""" """[docstring]"""
... pass
@abstractmethod
def save_result( def save_result(
self, self,
run_id: UUID, run_id: UUID,
@@ -1291,28 +1235,32 @@ class ITestRepository(Protocol):
lower_limit: float | None = None, lower_limit: float | None = None,
upper_limit: float | None = None upper_limit: float | None = None
) -> None: ) -> None:
"""Save a test result.""" """[docstring]"""
... pass
@abstractmethod
def save_measurements( def save_measurements(
self, self,
run_id: UUID, run_id: UUID,
measurements: list["Measurement"] measurements: list["Measurement"]
) -> None: ) -> None:
"""Save batch of measurements to Parquet.""" """[docstring]"""
... pass
@abstractmethod
def get_run(self, run_id: UUID) -> "TestRun": def get_run(self, run_id: UUID) -> "TestRun":
"""Get test run by ID.""" """[docstring]"""
... pass
@abstractmethod
def get_results(self, run_id: UUID) -> list["TestResult"]: def get_results(self, run_id: UUID) -> list["TestResult"]:
"""Get all results for a test run.""" """[docstring]"""
... pass
@abstractmethod
def get_measurements_dataframe(self, run_id: UUID): def get_measurements_dataframe(self, run_id: UUID):
"""Get measurements as pandas DataFrame.""" """[docstring]"""
... pass
``` ```
--- ---
@@ -1366,14 +1314,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 +1339,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 +1397,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 +1560,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 +1596,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

@@ -0,0 +1,460 @@
# Sprint 18: PDF Report Generation
| Document ID | DEV-002 |
|-------------|---------|
| Version | 1.0.0 |
| Status | Draft |
| Author | Kai Chappell |
| Created | 2026-01-29 |
| Last Updated | 2026-01-29 |
---
## Purpose
This document defines **Sprint 18** of py-dvt-ate development: automated PDF report generation from test results. Reports are designed to be professional and well-presented for recruiters/clients evaluating the simulation platform.
For project context, see:
- `01_requirements.md` - What the system must do
- `02_technical_specification.md` - How to implement
- `03_architecture_decisions.md` - Why decisions were made
- `04_development_plan.md` - Phase 1 MVP sprints (1-17)
---
## Feature Overview
Add automated PDF report generation with:
- Professional, well-presented layout suitable for external stakeholders
- Clean UX with easy download from CLI and dashboard
- Test metadata, results table with pass/fail status, and measurement charts
- Configurable company branding
---
## Design Principles
Following existing project patterns:
1. **Small, Focused Commits** - Each task = 1 commit, ~50-150 lines changed
2. **Stubs First** - Define interfaces/types before implementation
3. **Test Alongside** - Write tests immediately after implementation
4. **UK English** - characterisation, behaviour, colour
5. **Minimal Context** - Each task completable with knowledge of 1-3 files
---
## Task Breakdown
### Task 18.1: Add reporting dependencies to pyproject.toml
- Add `matplotlib>=3.8` to reports optional dependency group
- Verify jinja2 and weasyprint already present
- **Files:** `pyproject.toml`
- **Commit:** "Add matplotlib to reports dependencies"
---
### Task 18.2: Create report data models
- Create `src/py_dvt_ate/reporting/models.py`
- Define `ReportConfig` dataclass:
- `company_name: str` - Company name for header
- `logo_path: Path | None` - Optional logo image path
- `include_charts: bool` - Whether to include charts
- `chart_dpi: int` - Chart resolution
- Define `ReportData` dataclass:
- `run: TestRun` - Test run metadata
- `results: list[TestResult]` - Scalar results with limits
- `measurements: pd.DataFrame | None` - Time-series data
- `charts: dict[str, str]` - Chart name to base64 PNG
- **Files:** `src/py_dvt_ate/reporting/models.py`
- **Commit:** "Add report data models"
---
### Task 18.3: Create reporting exceptions
- Create `src/py_dvt_ate/reporting/exceptions.py`
- Define exception hierarchy:
- `ReportingError` - Base exception
- `ReportGenerationError` - General generation failure
- `TemplateRenderError` - HTML rendering failure
- `PDFConversionError` - HTML to PDF conversion failure
- `ChartGenerationError` - Chart generation failure
- **Files:** `src/py_dvt_ate/reporting/exceptions.py`
- **Commit:** "Add reporting exception classes"
---
### Task 18.4: Create CSS stylesheet for reports
- Create `src/py_dvt_ate/reporting/templates/styles.css`
- Professional styling:
- A4 page setup with margins
- Header with company branding
- Footer with page numbers
- Data tables with borders
- Status badges (pass=green, fail=red, info=blue)
- Summary cards with colour coding
- Chart containers
- Print-optimised with page breaks
- **Files:** `src/py_dvt_ate/reporting/templates/styles.css`
- **Commit:** "Add professional CSS stylesheet for reports"
---
### Task 18.5: Create base HTML template
- Create `src/py_dvt_ate/reporting/templates/base.html`
- Jinja2 base template with:
- `<head>` with CSS include
- Header block with company name, logo, report metadata
- Content block (for child templates)
- Footer block with confidentiality notice, page numbers
- WeasyPrint `@page` rules for PDF pagination
- **Files:** `src/py_dvt_ate/reporting/templates/base.html`
- **Commit:** "Add base HTML report template"
---
### Task 18.6: Create test report template
- Create `src/py_dvt_ate/reporting/templates/test_report.html`
- Extends `base.html` with sections:
- **Test Overview**: name, description, status, timestamps, duration, operator
- **Results Summary**: total/pass/fail cards with counts
- **Results Table**: parameter, value, unit, limits, pass/fail badge
- **Charts**: voltage vs temperature (if available)
- **Configuration**: test config JSON (optional)
- Jinja2 filters for formatting (floats, dates)
- **Files:** `src/py_dvt_ate/reporting/templates/test_report.html`
- **Commit:** "Add test report HTML template"
---
### Task 18.7: Implement HTML renderer
- Create `src/py_dvt_ate/reporting/renderers/__init__.py`
- Create `src/py_dvt_ate/reporting/renderers/html.py`
- `HTMLRenderer` class:
- Constructor takes `ReportConfig`
- Uses `jinja2.Environment` with `PackageLoader`
- `render(report_data: ReportData) -> str` method
- Custom filters for number formatting
- Template loading from `py_dvt_ate.reporting.templates` package
- **Files:** `src/py_dvt_ate/reporting/renderers/html.py`, `src/py_dvt_ate/reporting/renderers/__init__.py`
- **Commit:** "Implement HTML renderer with Jinja2"
---
### Task 18.8: Implement PDF renderer
- Create `src/py_dvt_ate/reporting/renderers/pdf.py`
- `PDFRenderer` class:
- `render_to_file(html: str, output_path: Path) -> None`
- `render_to_bytes(html: str) -> bytes`
- Use WeasyPrint `HTML(string=html).write_pdf()`
- Handle WeasyPrint warnings gracefully
- **Files:** `src/py_dvt_ate/reporting/renderers/pdf.py`
- **Commit:** "Implement PDF renderer with WeasyPrint"
---
### Task 18.9: Implement chart generator
- Create `src/py_dvt_ate/reporting/charts/__init__.py`
- Create `src/py_dvt_ate/reporting/charts/matplotlib_charts.py`
- `ChartGenerator` class:
- Constructor takes `ReportConfig` (for DPI)
- `_setup_style()` - Configure matplotlib for professional appearance
- `generate_voltage_vs_temperature(measurements: DataFrame) -> str`
- Scatter plot with trend line
- Calculate and display slope (ppm/C)
- Return base64-encoded PNG
- `generate_all(run, results, measurements) -> dict[str, str]`
- Dispatch to appropriate chart methods based on test type
- Use `matplotlib.use('Agg')` for non-interactive backend
- **Files:** `src/py_dvt_ate/reporting/charts/matplotlib_charts.py`, `src/py_dvt_ate/reporting/charts/__init__.py`
- **Commit:** "Implement matplotlib chart generator"
---
### Task 18.10: Implement ReportGenerator class
- Create `src/py_dvt_ate/reporting/generator.py`
- `IReportGenerator` Protocol:
- `generate(run_id: UUID, output_path: Path | None) -> Path`
- `generate_bytes(run_id: UUID) -> bytes`
- `ReportGenerator` class:
- Constructor: `repository`, `config`, `output_dir`
- Private: `_html_renderer`, `_pdf_renderer`, `_chart_generator`
- `_gather_data(run_id: UUID) -> ReportData`
- Fetch run, results, measurements from repository
- Generate charts if measurements available
- `_generate_output_path(run: TestRun) -> Path`
- Format: `{test_name}_{run_id_short}_{timestamp}.pdf`
- Error handling with appropriate exception types
- **Files:** `src/py_dvt_ate/reporting/generator.py`
- **Commit:** "Implement ReportGenerator class"
---
### Task 18.11: Update reporting module exports
- Update `src/py_dvt_ate/reporting/__init__.py`
- Export public API:
- `ReportGenerator`, `IReportGenerator`
- `ReportConfig`, `ReportData`
- All exception classes
- Add module docstring with usage example
- Lazy imports to handle missing optional dependencies
- **Files:** `src/py_dvt_ate/reporting/__init__.py`
- **Commit:** "Update reporting module public API"
---
### Task 18.12: Add ReportingConfig to app config
- Update `src/py_dvt_ate/app/config.py`
- Add `ReportingConfig` Pydantic model:
- `company_name: str = "DVT Engineering"`
- `logo_path: str | None = None`
- `include_charts: bool = True`
- `chart_dpi: int = 150`
- Add `reporting: ReportingConfig` to `AppConfig`
- **Files:** `src/py_dvt_ate/app/config.py`
- **Commit:** "Add ReportingConfig to application config"
---
### Task 18.13: Add reporting section to default.yaml
- Update `config/default.yaml`
- Add `reporting:` section with all options
- Document each option with comments
- **Files:** `config/default.yaml`
- **Commit:** "Add reporting configuration to default.yaml"
---
### Task 18.14: Add list-runs CLI command
- Update `src/py_dvt_ate/app/cli.py`
- Add `list-runs` command:
- `--limit` option (default 10)
- `--config` option for config file
- Output format: `{id:8} {test_name:15} {status:8} {timestamp}`
- Load repository from config
- **Files:** `src/py_dvt_ate/app/cli.py`
- **Commit:** "Add list-runs CLI command"
---
### Task 18.15: Add export-report CLI command
- Update `src/py_dvt_ate/app/cli.py`
- Add `export-report` command:
- `run_id` argument (required)
- `--output` / `-o` option for output path
- `--company` option for company name override
- `--config` option for config file
- Support short (8-char) and full UUID lookup
- Display progress and result path
- **Files:** `src/py_dvt_ate/app/cli.py`
- **Commit:** "Add export-report CLI command"
---
### Task 18.16: Add PDF download to dashboard
- Update `src/py_dvt_ate/app/dashboard/app.py`
- In results viewer page, add:
- "Generate PDF Report" button (primary)
- `st.download_button` for PDF download
- Progress spinner during generation
- Error handling for missing dependencies
- Store generated PDF in `st.session_state`
- **Files:** `src/py_dvt_ate/app/dashboard/app.py`
- **Commit:** "Add PDF download button to dashboard"
---
### Task 18.17: Add reporting unit tests
- Create `tests/unit/reporting/__init__.py`
- Create `tests/unit/reporting/test_models.py`
- Test ReportConfig and ReportData creation
- Test default values
- Create `tests/unit/reporting/test_html_renderer.py`
- Test template rendering with mock data
- Test custom filters
- Create `tests/unit/reporting/test_chart_generator.py`
- Test chart generation produces valid base64
- Test with sample DataFrame
- **Files:** `tests/unit/reporting/`
- **Commit:** "Add reporting unit tests"
---
### Task 18.18: Add reporting integration test
- Create `tests/integration/test_report_generation.py`
- End-to-end test:
- Create test run with sample results in repository
- Generate PDF report
- Verify PDF file created and non-empty
- Optionally verify PDF structure (page count)
- Use pytest fixtures for repository setup
- **Files:** `tests/integration/test_report_generation.py`
- **Commit:** "Add report generation integration test"
---
### Task 18.19: Update CHANGELOG
- Update `CHANGELOG.md`
- Add `## [Unreleased]` section if not present
- Document:
- New `export-report` CLI command
- New `list-runs` CLI command
- Dashboard PDF download feature
- Reporting module with PDF/HTML generation
- **Files:** `CHANGELOG.md`
- **Commit:** "Update CHANGELOG with report generation feature"
---
## File Structure (New Files)
```
src/py_dvt_ate/reporting/
├── __init__.py # Task 18.11 - Public API
├── models.py # Task 18.2 - ReportConfig, ReportData
├── exceptions.py # Task 18.3 - Exception hierarchy
├── generator.py # Task 18.10 - ReportGenerator
├── renderers/
│ ├── __init__.py # Task 18.7
│ ├── html.py # Task 18.7 - HTMLRenderer
│ └── pdf.py # Task 18.8 - PDFRenderer
├── charts/
│ ├── __init__.py # Task 18.9
│ └── matplotlib_charts.py # Task 18.9 - ChartGenerator
└── templates/
├── styles.css # Task 18.4 - CSS
├── base.html # Task 18.5 - Base template
└── test_report.html # Task 18.6 - Report template
tests/unit/reporting/
├── __init__.py # Task 18.17
├── test_models.py # Task 18.17
├── test_html_renderer.py # Task 18.17
└── test_chart_generator.py # Task 18.17
tests/integration/
└── test_report_generation.py # Task 18.18
```
---
## Files to Modify
| File | Tasks | Changes |
|------|-------|---------|
| `pyproject.toml` | 18.1 | Add matplotlib to reports deps |
| `src/py_dvt_ate/app/config.py` | 18.12 | Add ReportingConfig |
| `config/default.yaml` | 18.13 | Add reporting section |
| `src/py_dvt_ate/app/cli.py` | 18.14, 18.15 | Add list-runs, export-report |
| `src/py_dvt_ate/app/dashboard/app.py` | 18.16 | Add PDF download |
| `CHANGELOG.md` | 18.19 | Document new features |
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| jinja2 | >=3.1 | HTML template rendering |
| weasyprint | >=60.0 | HTML to PDF conversion |
| matplotlib | >=3.8 | Chart generation |
All in `[project.optional-dependencies] reports` group.
Install with: `pip install py-dvt-ate[reports]`
---
## Verification
### CLI Verification
```bash
# List recent test runs
py-dvt-ate list-runs
# Generate PDF report
py-dvt-ate export-report <run_id>
# With options
py-dvt-ate export-report <run_id> -o ./my_report.pdf --company "Acme Corp"
# View generated PDF
xdg-open ./data/reports/*.pdf
```
### Dashboard Verification
```bash
# Start dashboard
streamlit run src/py_dvt_ate/app/dashboard/app.py
# In browser:
# 1. Navigate to Results Viewer
# 2. Select a test run
# 3. Click "Generate PDF Report"
# 4. Click "Download PDF Report"
# 5. Open downloaded PDF
```
### Test Verification
```bash
# Run unit tests
pytest tests/unit/reporting/ -v
# Run integration test
pytest tests/integration/test_report_generation.py -v
# Check coverage
pytest tests/unit/reporting/ --cov=py_dvt_ate.reporting --cov-report=term-missing
```
---
## Task Progress
| Task | Status | Description |
|------|--------|-------------|
| 18.1 | pending | Add matplotlib dependency |
| 18.2 | pending | Report data models |
| 18.3 | pending | Reporting exceptions |
| 18.4 | pending | CSS stylesheet |
| 18.5 | pending | Base HTML template |
| 18.6 | pending | Test report template |
| 18.7 | pending | HTML renderer |
| 18.8 | pending | PDF renderer |
| 18.9 | pending | Chart generator |
| 18.10 | pending | ReportGenerator class |
| 18.11 | pending | Module exports |
| 18.12 | pending | App config update |
| 18.13 | pending | default.yaml update |
| 18.14 | pending | list-runs CLI |
| 18.15 | pending | export-report CLI |
| 18.16 | pending | Dashboard download |
| 18.17 | pending | Unit tests |
| 18.18 | pending | Integration test |
| 18.19 | pending | CHANGELOG update |
---
**End of Sprint 18 Plan**

View File

@@ -30,6 +30,7 @@ api = [
reports = [ reports = [
"jinja2>=3.1", "jinja2>=3.1",
"weasyprint>=60.0", "weasyprint>=60.0",
"matplotlib>=3.8",
] ]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
@@ -37,12 +38,14 @@ dev = [
"pytest-asyncio>=0.21", "pytest-asyncio>=0.21",
"ruff>=0.1", "ruff>=0.1",
"mypy>=1.0", "mypy>=1.0",
"types-PyYAML>=6.0",
"pandas-stubs>=2.0",
] ]
[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"]
@@ -81,10 +84,15 @@ disallow_incomplete_defs = true
module = [ module = [
"streamlit.*", "streamlit.*",
"plotly.*", "plotly.*",
"weasyprint.*",
"matplotlib.*",
] ]
ignore_missing_imports = true 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"

View File

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

367
src/py_dvt_ate/app/cli.py Normal file
View File

@@ -0,0 +1,367 @@
"""Command-line interface for py_dvt_ate."""
from typing import Annotated
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[
bool | None,
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,
)
@app.command(name="list-tests")
def list_tests_cmd() -> None:
"""List all available DVT tests."""
from py_dvt_ate.app.test_commands import list_tests
list_tests()
@app.command(name="run-test")
def run_test_cmd(
test_name: Annotated[
str,
typer.Argument(help="Name of the test to run (use list-tests to see available tests)."),
],
config_file: Annotated[
str | None,
typer.Option("--config", "-c", help="Path to configuration YAML file."),
] = None,
operator: Annotated[
str | None,
typer.Option("--operator", "-o", help="Operator identifier (e.g., email address)."),
] = None,
description: Annotated[
str | None,
typer.Option("--description", "-d", help="Test run description."),
] = None,
) -> None:
"""Run a specific DVT test.
The test will connect to instruments based on the configuration file
(default: config/default.yaml). Results are stored in the data directory.
"""
from py_dvt_ate.app.test_commands import run_test
run_test(
test_name=test_name,
config_file=config_file,
operator=operator,
description=description,
)
@app.command(name="list-runs")
def list_runs_cmd(
config_file: Annotated[
str | None,
typer.Option("--config", "-c", help="Path to configuration YAML file."),
] = None,
limit: Annotated[
int,
typer.Option("--limit", "-n", help="Maximum number of runs to display."),
] = 20,
) -> None:
"""List recent test runs with their IDs.
Shows a table of recent test runs including the short ID (for use with
export-report), test name, status, and timestamp.
"""
from pathlib import Path
from rich.console import Console
from rich.table import Table
from py_dvt_ate.app.config import load_config
from py_dvt_ate.data.repository import SQLiteRepository
console = Console()
# Load config
if config_file is None:
config_path = Path("config/default.yaml")
if config_path.exists():
config_file = str(config_path)
config = load_config(config_file)
# Create repository
repo = SQLiteRepository(
db_path=config.data.database_path,
measurements_dir=config.data.measurements_dir,
)
try:
runs = repo.get_all_runs()
if not runs:
console.print("[yellow]No test runs found.[/yellow]")
return
# Limit results
runs = runs[:limit]
# Create table
table = Table(title="Recent Test Runs")
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Test Name", style="white")
table.add_column("Status", style="white")
table.add_column("Started", style="dim")
for run in runs:
# Format status with colour
status = run.status.value.upper()
if status == "PASSED":
status_styled = f"[green]{status}[/green]"
elif status == "FAILED":
status_styled = f"[red]{status}[/red]"
elif status == "ERROR":
status_styled = f"[yellow]{status}[/yellow]"
else:
status_styled = status
table.add_row(
run.id[:8],
run.test_name,
status_styled,
run.started_at.strftime("%Y-%m-%d %H:%M:%S"),
)
console.print(table)
console.print(f"\n[dim]Showing {len(runs)} of {len(repo.get_all_runs())} runs[/dim]")
finally:
repo.close()
@app.command(name="export-report")
def export_report_cmd(
run_id: Annotated[
str,
typer.Argument(help="Test run ID (short 8-char or full UUID)."),
],
output: Annotated[
str | None,
typer.Option("--output", "-o", help="Output PDF file path."),
] = None,
company: Annotated[
str | None,
typer.Option("--company", help="Company name for report header."),
] = None,
config_file: Annotated[
str | None,
typer.Option("--config", "-c", help="Path to configuration YAML file."),
] = None,
) -> None:
"""Export a PDF report for a test run.
Generate a professional PDF report from test results. The run_id can be
the short 8-character ID shown by list-runs, or the full UUID.
Examples:
py-dvt-ate export-report abc12345
py-dvt-ate export-report abc12345 --output ./my_report.pdf
py-dvt-ate export-report abc12345 --company "My Company"
"""
from pathlib import Path
from uuid import UUID
from rich.console import Console
from py_dvt_ate.app.config import load_config
from py_dvt_ate.data.repository import SQLiteRepository
console = Console()
# Check for reporting dependencies
try:
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
except ImportError:
console.print(
"[red]Error:[/red] Report generation requires additional dependencies.\n"
"Install with: [cyan]pip install py_dvt_ate[reports][/cyan]"
)
raise typer.Exit(1) from None
# Load config
if config_file is None:
config_path = Path("config/default.yaml")
if config_path.exists():
config_file = str(config_path)
config = load_config(config_file)
# Create repository
repo = SQLiteRepository(
db_path=config.data.database_path,
measurements_dir=config.data.measurements_dir,
)
try:
# Resolve short ID to full UUID
full_run_id: UUID | None = None
if len(run_id) == 8:
# Short ID - need to find full UUID
all_runs = repo.get_all_runs()
matching_runs = [r for r in all_runs if r.id.startswith(run_id)]
if not matching_runs:
console.print(f"[red]Error:[/red] No test run found with ID starting with '{run_id}'")
raise typer.Exit(1)
elif len(matching_runs) > 1:
console.print(f"[red]Error:[/red] Multiple runs match '{run_id}'. Use full UUID.")
for run in matching_runs:
console.print(f" - {run.id} ({run.test_name})")
raise typer.Exit(1)
full_run_id = UUID(matching_runs[0].id)
else:
try:
full_run_id = UUID(run_id)
except ValueError:
console.print(f"[red]Error:[/red] Invalid run ID: '{run_id}'")
raise typer.Exit(1) from None
# Create report config
report_config = ReportConfig(
company_name=company or config.reporting.company_name,
logo_path=Path(config.reporting.logo_path) if config.reporting.logo_path else None,
include_charts=config.reporting.include_charts,
chart_dpi=config.reporting.chart_dpi,
)
# Create generator
generator = ReportGenerator(
repository=repo,
config=report_config,
reports_dir=Path(config.data.reports_dir),
)
# Generate report
console.print(f"[cyan]Generating report for run {str(full_run_id)[:8]}...[/cyan]")
output_path = Path(output) if output else None
pdf_path = generator.generate(full_run_id, output_path)
console.print(f"[green]Report saved to:[/green] {pdf_path}")
except typer.Exit:
raise
except Exception as e:
console.print(f"[red]Error generating report:[/red] {e}")
raise typer.Exit(1) from None
finally:
repo.close()
@app.command(name="query")
def query_cmd(
instrument: Annotated[
str,
typer.Argument(help="Instrument to query (chamber, psu, or dmm)."),
],
command: Annotated[
str,
typer.Argument(help="SCPI command to send (e.g., *IDN?, TEMP:SETPOINT?)."),
],
config_file: Annotated[
str | None,
typer.Option("--config", "-c", help="Path to configuration YAML file."),
] = None,
) -> None:
"""Send a SCPI command to an instrument and print the response.
Useful for debugging and manual instrument control. Connect to
instruments based on configuration and send raw SCPI commands.
Examples:
py-dvt-ate query chamber "*IDN?"
py-dvt-ate query psu "VOLT? 1"
py-dvt-ate query dmm "MEAS:VOLT:DC?"
"""
from py_dvt_ate.app.instrument_commands import query_instrument
query_instrument(
instrument=instrument,
command=command,
config_file=config_file,
)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,210 @@
"""Configuration models for py_dvt_ate.
This module defines Pydantic models for all configuration sections.
Configuration can be loaded from YAML files and validated at runtime.
"""
import os
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field
class SimulatorConfig(BaseModel):
"""Configuration for simulator instrument backend."""
host: str = "localhost"
thermal_chamber_port: int = 5001
power_supply_port: int = 5002
multimeter_port: int = 5003
class PyVISAConfig(BaseModel):
"""Configuration for PyVISA instrument backend."""
thermal_chamber: str | None = None
power_supply: str | None = None
multimeter: str | None = None
class InstrumentsConfig(BaseModel):
"""Instrument backend configuration."""
backend: Literal["simulator", "pyvisa"] = "simulator"
simulator: SimulatorConfig = Field(default_factory=SimulatorConfig)
pyvisa: PyVISAConfig = Field(default_factory=PyVISAConfig)
class ThermalConfig(BaseModel):
"""Thermal physics parameters."""
chamber_time_constant_s: float = 30.0
case_time_constant_s: float = 5.0
theta_jc: float = 15.0 # °C/W (junction to case)
theta_ca: float = 5.0 # °C/W (case to ambient)
class ChamberConfig(BaseModel):
"""Thermal chamber behaviour parameters."""
ramp_rate_c_per_min: float = 10.0
stability_window_c: float = 0.5
stability_time_s: float = 30.0
class PhysicsConfig(BaseModel):
"""Physics simulation parameters."""
update_rate_hz: float = 100.0
thermal: ThermalConfig = Field(default_factory=ThermalConfig)
chamber: ChamberConfig = Field(default_factory=ChamberConfig)
class DUTParameters(BaseModel):
"""DUT model parameters."""
nominal_output_voltage: float = 3.3
tempco_ppm_per_c: float = 50.0
quiescent_current_ua: float = 50.0
quiescent_current_tempco: float = 0.003
dropout_voltage: float = 0.3
class DUTConfig(BaseModel):
"""DUT model configuration."""
model: str = "ldo"
parameters: DUTParameters = Field(default_factory=DUTParameters)
class DataConfig(BaseModel):
"""Data storage paths."""
database_path: str = "./data/py_dvt_ate.db"
measurements_dir: str = "./data/measurements"
reports_dir: str = "./data/reports"
class LoggingConfig(BaseModel):
"""Logging configuration."""
level: str = "INFO"
file: str = "./data/logs/py_dvt_ate.log"
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
class DashboardConfig(BaseModel):
"""Dashboard (Streamlit) configuration."""
enabled: bool = True
port: int = 8501
class APIConfig(BaseModel):
"""API server configuration (Phase 2)."""
enabled: bool = False
host: str = "0.0.0.0"
port: int = 8000
class ReportingConfig(BaseModel):
"""PDF report generation configuration."""
company_name: str = "py_dvt_ate"
logo_path: str | None = None
include_charts: bool = True
chart_dpi: int = 150
class AppConfig(BaseModel):
"""Root configuration model."""
instruments: InstrumentsConfig = Field(default_factory=InstrumentsConfig)
physics: PhysicsConfig = Field(default_factory=PhysicsConfig)
dut: DUTConfig = Field(default_factory=DUTConfig)
data: DataConfig = Field(default_factory=DataConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
dashboard: DashboardConfig = Field(default_factory=DashboardConfig)
api: APIConfig = Field(default_factory=APIConfig)
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
def _apply_env_overrides(config_dict: dict[str, Any]) -> None:
"""Apply environment variable overrides to config dictionary.
Environment variables follow the pattern: PYDVTATE__{SECTION}__{KEY}
For nested keys, use double underscores: PYDVTATE__{SECTION}__{SUBSECTION}__{KEY}
Examples:
PYDVTATE__INSTRUMENTS__BACKEND=pyvisa
PYDVTATE__PHYSICS__UPDATE_RATE_HZ=50.0
PYDVTATE__SIMULATOR__HOST=192.168.1.100
"""
prefix = "PYDVTATE__"
for env_key, env_value in os.environ.items():
if not env_key.startswith(prefix):
continue
# Remove prefix and split into parts
key_parts = env_key[len(prefix) :].lower().split("__")
# Navigate/create nested structure
current = config_dict
for part in key_parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
# Set the final value
final_key = key_parts[-1]
# Try to parse as YAML to handle types (int, float, bool, etc.)
try:
current[final_key] = yaml.safe_load(env_value)
except yaml.YAMLError:
# If parsing fails, use as string
current[final_key] = env_value
def load_config(config_path: str | Path | None = None) -> AppConfig:
"""Load configuration from YAML file with environment variable overrides.
Args:
config_path: Path to YAML configuration file. If None, uses defaults only.
Returns:
Validated AppConfig instance.
Raises:
FileNotFoundError: If config_path is provided but does not exist.
yaml.YAMLError: If YAML file is malformed.
pydantic.ValidationError: If configuration is invalid.
Environment Variables:
Configuration can be overridden using environment variables with the
pattern PYDVTATE__{SECTION}__{KEY}. For example:
PYDVTATE__INSTRUMENTS__BACKEND=pyvisa
PYDVTATE__PHYSICS__UPDATE_RATE_HZ=50.0
"""
# Start with empty dict (will use Pydantic defaults)
config_dict: dict[str, Any] = {}
# Load from YAML file if provided
if config_path is not None:
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with path.open("r") as f:
loaded = yaml.safe_load(f)
if loaded is not None:
config_dict = loaded
# Apply environment variable overrides
_apply_env_overrides(config_dict)
# Validate and return
return AppConfig(**config_dict)

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
"""Instrument query commands for CLI."""
import typer
from py_dvt_ate.app.config import load_config
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
def query_instrument(
instrument: str,
command: str,
config_file: str | None = None,
) -> None:
"""Send a SCPI command to an instrument and print the response.
Args:
instrument: Instrument to query (chamber, psu, or dmm).
command: SCPI command to send.
config_file: Path to configuration YAML file.
"""
# Load configuration
config_path = config_file or "config/default.yaml"
try:
config = load_config(config_path)
except FileNotFoundError as err:
typer.echo(f"Error: Configuration file not found: {config_path}", err=True)
raise typer.Exit(code=1) from err
except Exception as e:
typer.echo(f"Error loading configuration: {e}", err=True)
raise typer.Exit(code=1) from e
# Create instruments
try:
# Convert AppConfig to InstrumentConfig
inst_config = InstrumentConfig(
backend=config.instruments.backend,
simulator_host=config.instruments.simulator.host,
chamber_port=config.instruments.simulator.thermal_chamber_port,
psu_port=config.instruments.simulator.power_supply_port,
dmm_port=config.instruments.simulator.multimeter_port,
chamber_visa=config.instruments.pyvisa.thermal_chamber,
psu_visa=config.instruments.pyvisa.power_supply,
dmm_visa=config.instruments.pyvisa.multimeter,
)
instruments = InstrumentFactory.create(inst_config)
except Exception as e:
typer.echo(f"Error connecting to instruments: {e}", err=True)
raise typer.Exit(code=1) from e
# Send command to the specified instrument
try:
# Access the transport layer to send raw commands
if instrument == "chamber":
response = instruments.chamber._transport.query(command) # type: ignore[attr-defined]
elif instrument == "psu":
response = instruments.psu._transport.query(command) # type: ignore[attr-defined]
elif instrument == "dmm":
response = instruments.dmm._transport.query(command) # type: ignore[attr-defined]
else:
typer.echo(f"Error: Unknown instrument '{instrument}'", err=True)
typer.echo("Valid instruments: chamber, psu, dmm", err=True)
raise typer.Exit(code=1)
if response:
typer.echo(response)
except Exception as e:
typer.echo(f"Error sending command: {e}", err=True)
raise typer.Exit(code=1) from e

View File

@@ -0,0 +1,175 @@
"""Test execution commands for CLI."""
import importlib
import inspect
from pathlib import Path
import typer
from py_dvt_ate.app.config import load_config
from py_dvt_ate.data.repository import SQLiteRepository
from py_dvt_ate.framework.context import ITest
from py_dvt_ate.framework.runner import TestRunner
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
def _discover_tests() -> dict[str, type]:
"""Discover all available tests by scanning the tests package.
Returns:
Dictionary mapping test names to test classes.
"""
tests: dict[str, type] = {}
# Find the tests package directory
import py_dvt_ate.tests
tests_pkg_path = Path(py_dvt_ate.tests.__file__).parent
# Scan all Python files in the tests package
for py_file in tests_pkg_path.rglob("*.py"):
if py_file.name.startswith("_"):
continue
# Convert file path to module name
rel_path = py_file.relative_to(tests_pkg_path.parent)
module_name = "py_dvt_ate." + str(rel_path.with_suffix("")).replace("/", ".").replace("\\", ".")
try:
module = importlib.import_module(module_name)
# Find all classes that implement ITest
for _name, obj in inspect.getmembers(module, inspect.isclass):
if (
obj is not ITest
and issubclass(obj, ITest)
and not inspect.isabstract(obj)
and hasattr(obj, "name")
):
# Create instance to get the name property
instance = obj()
tests[instance.name] = obj
except (ImportError, AttributeError):
continue
return tests
def list_tests() -> None:
"""List all available DVT tests."""
tests = _discover_tests()
if not tests:
typer.echo("No tests found.")
return
typer.echo("Available DVT tests:")
typer.echo("")
for test_name in sorted(tests.keys()):
test_class = tests[test_name]
instance = test_class()
typer.echo(f" {test_name:15s} {instance.description}")
def run_test(
test_name: str,
config_file: str | None = None,
operator: str | None = None,
description: str | None = None,
) -> None:
"""Run a specific DVT test.
Args:
test_name: Name of the test to run.
config_file: Path to configuration YAML file.
operator: Operator identifier (e.g., email address).
description: Test run description.
"""
# Discover available tests
tests = _discover_tests()
if test_name not in tests:
typer.echo(f"Error: Test \'{test_name}\' not found.", err=True)
typer.echo("", err=True)
typer.echo("Available tests:", err=True)
for name in sorted(tests.keys()):
typer.echo(f" - {name}", err=True)
raise typer.Exit(code=1)
# Load configuration
config_path = config_file or "config/default.yaml"
try:
config = load_config(config_path)
except FileNotFoundError as err:
typer.echo(f"Error: Configuration file not found: {config_path}", err=True)
typer.echo("Run with --config to specify a different config file.", err=True)
raise typer.Exit(code=1) from err
except Exception as e:
typer.echo(f"Error loading configuration: {e}", err=True)
raise typer.Exit(code=1) from e
# Create repository
try:
repository = SQLiteRepository(config.data.database_path)
except Exception as e:
typer.echo(f"Error initialising repository: {e}", err=True)
raise typer.Exit(code=1) from e
# Create instruments
typer.echo(f"Connecting to instruments ({config.instruments.backend})...")
try:
# Convert AppConfig to InstrumentConfig
inst_config = InstrumentConfig(
backend=config.instruments.backend,
simulator_host=config.instruments.simulator.host,
chamber_port=config.instruments.simulator.thermal_chamber_port,
psu_port=config.instruments.simulator.power_supply_port,
dmm_port=config.instruments.simulator.multimeter_port,
chamber_visa=config.instruments.pyvisa.thermal_chamber,
psu_visa=config.instruments.pyvisa.power_supply,
dmm_visa=config.instruments.pyvisa.multimeter,
)
instruments = InstrumentFactory.create(inst_config)
except Exception as e:
typer.echo(f"Error connecting to instruments: {e}", err=True)
raise typer.Exit(code=1) from e
# Create test instance
test_class = tests[test_name]
test = test_class()
# Run test
typer.echo(f"Running test: {test.name}")
typer.echo(f"Description: {test.description}")
typer.echo("")
try:
runner = TestRunner(repository)
run_id = runner.run_test(
test=test,
instruments=instruments,
operator=operator,
description=description,
)
# Retrieve final status
run = repository.get_run(run_id)
typer.echo("")
typer.echo(f"Test completed: {run.status.value}")
typer.echo(f"Run ID: {run_id}")
# Exit with appropriate code
if run.status.value == "PASSED":
raise typer.Exit(code=0)
else:
raise typer.Exit(code=1)
except KeyboardInterrupt:
typer.echo("")
typer.echo("Test interrupted by user.")
raise typer.Exit(code=130) from None
except Exception as e:
typer.echo(f"Error running test: {e}", err=True)
raise typer.Exit(code=1) from e

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

@@ -0,0 +1,14 @@
"""Data persistence layer.
Provides storage for test runs, results, and measurements using
SQLite for metadata and Parquet for time-series data.
"""
from py_dvt_ate.data.models import Measurement, TestResult, TestRun, TestStatus
__all__ = [
"Measurement",
"TestResult",
"TestRun",
"TestStatus",
]

View File

@@ -0,0 +1,83 @@
"""Data models for test persistence.
This module defines dataclasses representing test runs, results, and measurements.
These models map to SQLite tables (for metadata) and Parquet files (for time-series).
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class TestStatus(Enum):
"""Test run status."""
PENDING = "pending"
RUNNING = "running"
PASSED = "passed"
FAILED = "failed"
ERROR = "error"
SKIPPED = "skipped"
@dataclass
class TestRun:
"""Test run metadata.
Maps to the test_runs SQLite table.
"""
id: str # UUID
test_name: str
started_at: datetime
status: TestStatus
config_json: str # JSON string of test configuration
description: str | None = None
completed_at: datetime | None = None
operator: str | None = None
notes: str | None = None
created_at: datetime = field(default_factory=datetime.now)
@dataclass(frozen=True)
class TestResult:
"""Immutable test result with limits.
Maps to the test_results SQLite table.
Represents a single scalar measurement with pass/fail limits.
"""
id: str # UUID
test_run_id: str # Foreign key to test_runs.id
parameter: str
value: float
unit: str
measured_at: datetime
lower_limit: float | None = None
upper_limit: float | None = None
@property
def passed(self) -> bool | None:
"""Evaluate pass/fail. None if no limits defined."""
if self.lower_limit is None and self.upper_limit is None:
return None
lower_ok = self.lower_limit is None or self.value >= self.lower_limit
upper_ok = self.upper_limit is None or self.value <= self.upper_limit
return lower_ok and upper_ok
@dataclass(frozen=True)
class Measurement:
"""Immutable measurement record for time-series data.
Maps to Parquet files for efficient storage and analysis.
Includes measurement conditions (temperature, voltage, current) at time of measurement.
"""
timestamp: float # Seconds since epoch (high precision)
parameter: str
value: float
unit: str
temperature: float = 0.0 # Chamber temperature at measurement
input_voltage: float = 0.0 # DUT input voltage at measurement
load_current: float = 0.0 # DUT load current at measurement

View File

@@ -0,0 +1,415 @@
"""Data repository implementation using SQLite and Parquet.
This module provides SQLite-based storage for test run metadata and results.
Time-series measurements are stored separately in Parquet files.
"""
import json
import sqlite3
from abc import ABC, abstractmethod
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
import pandas as pd
from py_dvt_ate.data.models import Measurement, TestResult, TestRun, TestStatus
class ITestRepository(ABC):
"""Repository interface for test data."""
@abstractmethod
def create_run(
self,
test_name: str,
config: dict[str, Any],
operator: str | None = None,
description: str | None = None,
) -> UUID:
"""Create a new test run and return its ID."""
@abstractmethod
def update_run_status(self, run_id: UUID, status: TestStatus) -> None:
"""Update the status of a test run."""
@abstractmethod
def complete_run(self, run_id: UUID, status: TestStatus) -> None:
"""Mark a test run as complete with final status."""
@abstractmethod
def save_result(
self,
run_id: UUID,
parameter: str,
value: float,
unit: str,
lower_limit: float | None = None,
upper_limit: float | None = None,
) -> None:
"""Save a scalar test result."""
@abstractmethod
def save_measurements(
self,
run_id: UUID,
measurements: list[Measurement],
) -> None:
"""Save time-series measurements (implemented in Parquet extension)."""
@abstractmethod
def get_run(self, run_id: UUID) -> TestRun:
"""Retrieve test run metadata by ID."""
@abstractmethod
def get_results(self, run_id: UUID) -> list[TestResult]:
"""Retrieve all test results for a run."""
@abstractmethod
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
"""Retrieve measurements as pandas DataFrame."""
@abstractmethod
def get_all_runs(self) -> list[TestRun]:
"""Retrieve all test runs, ordered by started_at descending."""
def close(self) -> None:
"""Close repository and release resources. Optional to implement."""
class SQLiteRepository(ITestRepository):
"""SQLite-based repository for test data.
Stores test run metadata and scalar results in SQLite.
Time-series measurements are stored in Parquet files.
"""
def __init__(self, db_path: str | Path, measurements_dir: str | Path | None = None):
"""Initialise repository with database and measurements paths.
Args:
db_path: Path to SQLite database file
measurements_dir: Directory for Parquet measurement files
(defaults to db_path parent / "measurements")
"""
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
if measurements_dir is None:
self.measurements_dir = self.db_path.parent / "measurements"
else:
self.measurements_dir = Path(measurements_dir)
self.measurements_dir.mkdir(parents=True, exist_ok=True)
self._init_database()
def _init_database(self) -> None:
"""Create database tables if they don't exist."""
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS test_runs (
id TEXT PRIMARY KEY,
test_name TEXT NOT NULL,
description TEXT,
started_at TEXT NOT NULL,
completed_at TEXT,
status TEXT NOT NULL DEFAULT 'pending',
config_json TEXT NOT NULL,
operator TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS test_results (
id TEXT PRIMARY KEY,
test_run_id TEXT NOT NULL,
parameter TEXT NOT NULL,
value REAL NOT NULL,
unit TEXT,
lower_limit REAL,
upper_limit REAL,
passed INTEGER NOT NULL,
measured_at TEXT NOT NULL,
FOREIGN KEY (test_run_id) REFERENCES test_runs(id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_test_runs_status ON test_runs(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_test_runs_name ON test_runs(test_name)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_test_results_run ON test_results(test_run_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_test_results_param ON test_results(parameter)"
)
conn.commit()
def create_run(
self,
test_name: str,
config: dict[str, Any],
operator: str | None = None,
description: str | None = None,
) -> UUID:
"""Create a new test run and return its ID."""
run_id = uuid4()
started_at = datetime.now()
config_json = json.dumps(config)
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO test_runs (
id, test_name, description, started_at, status,
config_json, operator, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(run_id),
test_name,
description,
started_at.isoformat(),
TestStatus.PENDING.value,
config_json,
operator,
datetime.now().isoformat(),
),
)
conn.commit()
return run_id
def update_run_status(self, run_id: UUID, status: TestStatus) -> None:
"""Update the status of a test run."""
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE test_runs SET status = ? WHERE id = ?",
(status.value, str(run_id)),
)
conn.commit()
def complete_run(self, run_id: UUID, status: TestStatus) -> None:
"""Mark a test run as complete with final status."""
completed_at = datetime.now()
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
UPDATE test_runs
SET status = ?, completed_at = ?
WHERE id = ?
""",
(status.value, completed_at.isoformat(), str(run_id)),
)
conn.commit()
def save_result(
self,
run_id: UUID,
parameter: str,
value: float,
unit: str,
lower_limit: float | None = None,
upper_limit: float | None = None,
) -> None:
"""Save a scalar test result."""
result_id = uuid4()
measured_at = datetime.now()
# Calculate pass/fail
passed = 1 # Default to pass if no limits
if lower_limit is not None or upper_limit is not None:
lower_ok = lower_limit is None or value >= lower_limit
upper_ok = upper_limit is None or value <= upper_limit
passed = 1 if (lower_ok and upper_ok) else 0
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"""
INSERT INTO test_results (
id, test_run_id, parameter, value, unit,
lower_limit, upper_limit, passed, measured_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(result_id),
str(run_id),
parameter,
value,
unit,
lower_limit,
upper_limit,
passed,
measured_at.isoformat(),
),
)
conn.commit()
def save_measurements(
self,
run_id: UUID,
measurements: list[Measurement],
) -> None:
"""Save time-series measurements to Parquet file.
Measurements are stored in Parquet format for efficient time-series storage.
File path: {measurements_dir}/run_{run_id}/measurements.parquet
"""
if not measurements:
return
# Create run-specific directory
run_dir = self.measurements_dir / f"run_{run_id}"
run_dir.mkdir(parents=True, exist_ok=True)
# Convert measurements to DataFrame
data = {
"timestamp": [m.timestamp for m in measurements],
"parameter": [m.parameter for m in measurements],
"value": [m.value for m in measurements],
"unit": [m.unit for m in measurements],
"temperature": [m.temperature for m in measurements],
"input_voltage": [m.input_voltage for m in measurements],
"load_current": [m.load_current for m in measurements],
}
df = pd.DataFrame(data)
# Save to Parquet (append mode if file exists)
parquet_path = run_dir / "measurements.parquet"
if parquet_path.exists():
# Read existing data and append
existing_df = pd.read_parquet(parquet_path)
df = pd.concat([existing_df, df], ignore_index=True)
df.to_parquet(parquet_path, index=False, engine="pyarrow")
def get_run(self, run_id: UUID) -> TestRun:
"""Retrieve test run metadata by ID."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM test_runs WHERE id = ?",
(str(run_id),),
)
row = cursor.fetchone()
if row is None:
msg = f"Test run {run_id} not found"
raise ValueError(msg)
return TestRun(
id=row["id"],
test_name=row["test_name"],
description=row["description"],
started_at=datetime.fromisoformat(row["started_at"]),
completed_at=(
datetime.fromisoformat(row["completed_at"])
if row["completed_at"]
else None
),
status=TestStatus(row["status"]),
config_json=row["config_json"],
operator=row["operator"],
notes=row["notes"],
created_at=datetime.fromisoformat(row["created_at"]),
)
def get_results(self, run_id: UUID) -> list[TestResult]:
"""Retrieve all test results for a run."""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM test_results WHERE test_run_id = ?",
(str(run_id),),
)
rows = cursor.fetchall()
return [
TestResult(
id=row["id"],
test_run_id=row["test_run_id"],
parameter=row["parameter"],
value=row["value"],
unit=row["unit"],
lower_limit=row["lower_limit"],
upper_limit=row["upper_limit"],
measured_at=datetime.fromisoformat(row["measured_at"]),
)
for row in rows
]
def get_measurements_dataframe(self, run_id: UUID) -> pd.DataFrame | None:
"""Retrieve measurements as pandas DataFrame from Parquet file.
Args:
run_id: Test run ID
Returns:
DataFrame with measurement data, or None if no measurements exist
"""
parquet_path = self.measurements_dir / f"run_{run_id}" / "measurements.parquet"
if not parquet_path.exists():
return None
return pd.read_parquet(parquet_path)
def get_all_runs(self) -> list[TestRun]:
"""Retrieve all test runs, ordered by started_at descending.
Returns:
List of all TestRun objects, newest first.
"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
SELECT id, test_name, started_at, status, config_json,
description, completed_at, operator, notes, created_at
FROM test_runs
ORDER BY started_at DESC
""")
rows = cursor.fetchall()
return [
TestRun(
id=row["id"],
test_name=row["test_name"],
started_at=datetime.fromisoformat(row["started_at"]),
status=TestStatus(row["status"]),
config_json=row["config_json"],
description=row["description"],
completed_at=(
datetime.fromisoformat(row["completed_at"])
if row["completed_at"]
else None
),
operator=row["operator"],
notes=row["notes"],
created_at=datetime.fromisoformat(row["created_at"]),
)
for row in rows
]
def close(self) -> None:
"""Close repository and release resources.
SQLite connections are managed via context managers and auto-close.
This method performs explicit cleanup for Windows file handle issues.
"""
# Force garbage collection to release any lingering connections
import gc
gc.collect()

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
"""Test execution framework.
Provides test sequencing, measurement logging, limit checking,
and runtime context management for DVT characterisation tests.
"""
from py_dvt_ate.framework.context import ITest, TestContext
from py_dvt_ate.framework.limits import Limit, LimitSet, check_value, evaluate_results
from py_dvt_ate.framework.logger import ITestLogger, TestLogger
from py_dvt_ate.framework.runner import TestRunner
__all__ = [
"ITest",
"ITestLogger",
"Limit",
"LimitSet",
"TestContext",
"TestLogger",
"TestRunner",
"check_value",
"evaluate_results",
]

View File

@@ -0,0 +1,111 @@
"""Test framework context and interface definitions.
This module defines the core abstractions for the test executive framework:
- TestContext: Runtime context passed to tests during execution
- ITest: Abstract base class that all DVT tests must implement
The test framework orchestrates test execution, measurement logging, and
result evaluation against limits.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from uuid import UUID
from py_dvt_ate.data.models import TestStatus
if TYPE_CHECKING:
# Avoid circular imports while maintaining type checking
from py_dvt_ate.framework.logger import ITestLogger
from py_dvt_ate.instruments.factory import InstrumentSet
@dataclass
class TestContext:
"""Runtime context for test execution.
Provides access to instruments, logging, and configuration during test
execution. Passed to each test's execute() method.
Attributes:
run_id: Unique identifier for this test run (UUID).
instruments: Hardware abstraction layer providing access to all instruments.
logger: Test logger for recording measurements and events.
config: Test-specific configuration dictionary.
"""
run_id: UUID
instruments: "InstrumentSet"
logger: "ITestLogger"
config: dict[str, Any]
class ITest(ABC):
"""Abstract base class for DVT test implementations.
All characterisation tests must inherit from this class and implement
the required properties and methods. The test runner uses these to
discover, describe, and execute tests.
Example:
class TempCoTest(ITest):
@property
def name(self) -> str:
return "tempco"
@property
def description(self) -> str:
return "Output voltage temperature coefficient"
def execute(self, context: TestContext) -> TestStatus:
# Test implementation...
return TestStatus.PASSED
"""
@property
@abstractmethod
def name(self) -> str:
"""Return the unique test identifier.
Used for test discovery and selection. Should be lowercase,
alphanumeric with underscores (e.g., "tempco", "load_regulation").
Returns:
Unique test name string.
"""
pass
@property
@abstractmethod
def description(self) -> str:
"""Return a human-readable test description.
Describes what the test measures or characterises. Displayed in
reports and user interfaces.
Returns:
Brief description of the test purpose.
"""
pass
@abstractmethod
def execute(self, context: TestContext) -> TestStatus:
"""Execute the test with the given context.
Contains the test logic: configure instruments, take measurements,
log results, and evaluate pass/fail. The test should use the
context.logger to record measurements and context.instruments to
control equipment.
Args:
context: Runtime context with instruments, logger, and config.
Returns:
Final test status (PASSED, FAILED, ERROR, etc.).
Raises:
Exception: If a critical error occurs during test execution.
The test runner will catch this and mark the test as ERROR.
"""
pass

View File

@@ -0,0 +1,238 @@
"""Limit checking utilities for test result evaluation.
This module provides utilities for evaluating measurements against specification
limits and determining pass/fail status. Used by tests to check if results meet
requirements and by the test runner to determine overall test status.
"""
from dataclasses import dataclass
from typing import Any
from py_dvt_ate.data.models import TestResult, TestStatus
@dataclass(frozen=True)
class Limit:
"""Specification limit for a parameter.
Represents a single limit specification with optional lower and upper bounds.
Used to define test specifications and evaluate pass/fail.
Attributes:
parameter: Parameter name this limit applies to.
lower: Optional lower limit (inclusive). None means no lower limit.
upper: Optional upper limit (inclusive). None means no upper limit.
unit: Unit of measurement for the limits.
Example:
temp_co_limit = Limit("temp_co", lower=-50.0, upper=50.0, unit="ppm/°C")
"""
parameter: str
lower: float | None = None
upper: float | None = None
unit: str = ""
def check(self, value: float) -> bool | None:
"""Check if a value is within this limit.
Args:
value: Value to check against limits.
Returns:
True if value is within limits, False if outside limits.
None if no limits are defined (informational parameter).
Example:
limit = Limit("v_out", lower=3.25, upper=3.35, unit="V")
limit.check(3.30) # Returns True
limit.check(3.40) # Returns False
"""
if self.lower is None and self.upper is None:
return None
lower_ok = self.lower is None or value >= self.lower
upper_ok = self.upper is None or value <= self.upper
return lower_ok and upper_ok
@dataclass(frozen=True)
class LimitSet:
"""Collection of limits for a test.
Groups multiple parameter limits together as a test specification.
Can be loaded from configuration or defined programmatically.
Attributes:
name: Name of this limit set (e.g., "nominal", "extended").
limits: Dictionary mapping parameter names to Limit objects.
Example:
limits = LimitSet(
name="nominal",
limits={
"temp_co": Limit("temp_co", -50.0, 50.0, "ppm/°C"),
"v_out": Limit("v_out", 3.25, 3.35, "V"),
}
)
"""
name: str
limits: dict[str, Limit]
def get_limit(self, parameter: str) -> Limit | None:
"""Get the limit for a specific parameter.
Args:
parameter: Parameter name to look up.
Returns:
Limit object if found, None if parameter has no limit defined.
"""
return self.limits.get(parameter)
def check(self, parameter: str, value: float) -> bool | None:
"""Check if a value is within limits for a parameter.
Args:
parameter: Parameter name.
value: Value to check.
Returns:
True if within limits, False if outside limits.
None if parameter has no limit defined.
"""
limit = self.get_limit(parameter)
if limit is None:
return None
return limit.check(value)
@classmethod
def from_dict(cls, name: str, limits_dict: dict[str, Any]) -> "LimitSet":
"""Create a LimitSet from a dictionary.
Useful for loading limit sets from YAML configuration files.
Args:
name: Name for this limit set.
limits_dict: Dictionary with parameter names as keys and limit
specifications as values. Each limit spec should have:
- "lower": Optional lower limit
- "upper": Optional upper limit
- "unit": Unit of measurement
Returns:
LimitSet instance.
Example:
config = {
"temp_co": {"lower": -50.0, "upper": 50.0, "unit": "ppm/°C"},
"v_out": {"lower": 3.25, "upper": 3.35, "unit": "V"},
}
limits = LimitSet.from_dict("nominal", config)
"""
limits = {}
for param, spec in limits_dict.items():
limits[param] = Limit(
parameter=param,
lower=spec.get("lower"),
upper=spec.get("upper"),
unit=spec.get("unit", ""),
)
return cls(name=name, limits=limits)
def check_value(
value: float,
lower: float | None = None,
upper: float | None = None,
) -> bool | None:
"""Check if a value is within specified limits.
Utility function for quick limit checking without creating Limit objects.
Args:
value: Value to check.
lower: Optional lower limit (inclusive).
upper: Optional upper limit (inclusive).
Returns:
True if value is within limits, False if outside limits.
None if no limits are specified.
Example:
check_value(3.30, lower=3.25, upper=3.35) # Returns True
check_value(3.40, lower=3.25, upper=3.35) # Returns False
check_value(3.30) # Returns None (no limits)
"""
if lower is None and upper is None:
return None
lower_ok = lower is None or value >= lower
upper_ok = upper is None or value <= upper
return lower_ok and upper_ok
def evaluate_results(results: list[TestResult]) -> TestStatus:
"""Evaluate a list of test results to determine overall status.
Aggregates multiple test results into a single pass/fail determination.
If any result fails its limits, the overall status is FAILED.
If all results pass (or have no limits), the overall status is PASSED.
Args:
results: List of TestResult objects to evaluate.
Returns:
TestStatus.PASSED if all results pass their limits.
TestStatus.FAILED if any result fails its limits.
TestStatus.PASSED if no results have limits defined (informational only).
Example:
results = [
TestResult(..., value=25.0, lower_limit=-50.0, upper_limit=50.0),
TestResult(..., value=3.30, lower_limit=3.25, upper_limit=3.35),
]
status = evaluate_results(results) # Returns TestStatus.PASSED
"""
if not results:
return TestStatus.PASSED
# Check if any result failed
for result in results:
if result.passed is False:
return TestStatus.FAILED
# All results passed (or had no limits)
return TestStatus.PASSED
def format_limit_violation(result: TestResult) -> str:
"""Format a limit violation message for a failed result.
Creates a human-readable message describing why a result failed.
Useful for logging and reporting.
Args:
result: TestResult that failed its limits.
Returns:
Formatted violation message.
Example:
result = TestResult(..., parameter="v_out", value=3.40,
lower_limit=3.25, upper_limit=3.35, unit="V")
message = format_limit_violation(result)
# Returns: "v_out: 3.400 V [FAIL] (limits: 3.250 to 3.350 V)"
"""
status = "PASS" if result.passed else "FAIL"
limits_str = ""
if result.lower_limit is not None and result.upper_limit is not None:
limits_str = f" (limits: {result.lower_limit:.3f} to {result.upper_limit:.3f} {result.unit})"
elif result.lower_limit is not None:
limits_str = f" (minimum: {result.lower_limit:.3f} {result.unit})"
elif result.upper_limit is not None:
limits_str = f" (maximum: {result.upper_limit:.3f} {result.unit})"
return f"{result.parameter}: {result.value:.3f} {result.unit} [{status}]{limits_str}"

View File

@@ -0,0 +1,222 @@
"""Test logger for recording measurements and events.
This module provides the logging infrastructure for DVT tests. The test logger
records time-series measurements, scalar results with limits, and event messages
during test execution.
"""
import time
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID
from py_dvt_ate.data.models import Measurement
from py_dvt_ate.data.repository import ITestRepository
class ITestLogger(ABC):
"""Abstract interface for test data logging.
Provides methods for logging measurements, results, and events during
test execution. Implementations are responsible for persisting this
data to the appropriate storage backend.
"""
@abstractmethod
def log_measurement(
self,
parameter: str,
value: float,
unit: str,
conditions: dict[str, float] | None = None,
) -> None:
"""Log a time-series measurement with environmental conditions.
Used for logging raw measurements taken during the test. These are
stored as time-series data for later analysis and plotting.
Args:
parameter: Measurement parameter name (e.g., "v_out", "i_q").
value: Measured value.
unit: Unit of measurement (e.g., "V", "A", "°C").
conditions: Optional environmental conditions at time of measurement:
- "temperature": Chamber temperature (°C)
- "input_voltage": DUT input voltage (V)
- "load_current": DUT load current (A)
Example:
logger.log_measurement(
"v_out", 3.301, "V",
conditions={"temperature": 25.0, "input_voltage": 5.0}
)
"""
pass
@abstractmethod
def log_result(
self,
parameter: str,
value: float,
unit: str,
lower_limit: float | None = None,
upper_limit: float | None = None,
) -> None:
"""Log a scalar test result with pass/fail limits.
Used for logging calculated or derived results that will be evaluated
against specification limits. These appear in test reports and determine
overall pass/fail status.
Args:
parameter: Result parameter name (e.g., "temp_co", "load_reg").
value: Calculated or measured value.
unit: Unit of measurement (e.g., "ppm/°C", "%", "mV").
lower_limit: Optional lower limit for pass/fail evaluation.
upper_limit: Optional upper limit for pass/fail evaluation.
Example:
logger.log_result(
"temp_co", 23.5, "ppm/°C",
lower_limit=-50.0, upper_limit=50.0
)
"""
pass
@abstractmethod
def log_event(self, message: str, level: str = "INFO") -> None:
"""Log a test event or message.
Used for logging informational messages, warnings, and errors during
test execution. Useful for debugging and understanding test flow.
Args:
message: Event message text.
level: Log level ("DEBUG", "INFO", "WARNING", "ERROR").
Example:
logger.log_event("Waiting for thermal stability", level="INFO")
"""
pass
@abstractmethod
def flush(self) -> None:
"""Flush any buffered data to storage.
Forces any buffered measurements or results to be written to the
underlying storage backend. Called automatically at end of test,
but can be called manually for long-running tests.
"""
pass
class TestLogger(ITestLogger):
"""Concrete test logger implementation using repository pattern.
Buffers measurements in memory and writes them in batches to a
repository for efficiency. Results and events are written immediately.
Attributes:
run_id: UUID of the test run this logger is associated with.
repository: Data repository for persisting measurements and results.
measurement_buffer: In-memory buffer of measurements awaiting write.
buffer_size: Number of measurements to buffer before auto-flush.
"""
def __init__(
self,
run_id: UUID,
repository: ITestRepository,
buffer_size: int = 100,
):
"""Initialise test logger.
Args:
run_id: UUID of the test run to associate logs with.
repository: Repository for persisting data.
buffer_size: Number of measurements to buffer before auto-flush.
Default 100 provides good balance of performance
and memory usage.
"""
self.run_id = run_id
self.repository = repository
self.buffer_size = buffer_size
self.measurement_buffer: list[Measurement] = []
def log_measurement(
self,
parameter: str,
value: float,
unit: str,
conditions: dict[str, float] | None = None,
) -> None:
"""Log a time-series measurement with environmental conditions.
Measurements are buffered in memory and written to the repository
in batches for efficiency.
"""
conditions = conditions or {}
measurement = Measurement(
timestamp=time.time(),
parameter=parameter,
value=value,
unit=unit,
temperature=conditions.get("temperature", 0.0),
input_voltage=conditions.get("input_voltage", 0.0),
load_current=conditions.get("load_current", 0.0),
)
self.measurement_buffer.append(measurement)
# Auto-flush when buffer is full
if len(self.measurement_buffer) >= self.buffer_size:
self.flush()
def log_result(
self,
parameter: str,
value: float,
unit: str,
lower_limit: float | None = None,
upper_limit: float | None = None,
) -> None:
"""Log a scalar test result with pass/fail limits.
Results are written immediately to the repository (not buffered).
"""
self.repository.save_result(
run_id=self.run_id,
parameter=parameter,
value=value,
unit=unit,
lower_limit=lower_limit,
upper_limit=upper_limit,
)
def log_event(self, message: str, level: str = "INFO") -> None:
"""Log a test event or message.
Events are currently logged to console. Future versions may
persist events to the repository.
"""
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
print(f"[{timestamp}] {level:7s} {message}")
def flush(self) -> None:
"""Flush buffered measurements to repository.
Writes all buffered measurements to the repository in a single
batch operation, then clears the buffer.
"""
if self.measurement_buffer:
self.repository.save_measurements(
run_id=self.run_id,
measurements=self.measurement_buffer,
)
self.measurement_buffer.clear()
def __del__(self) -> None:
"""Ensure buffered data is flushed on logger destruction."""
try:
self.flush()
except Exception:
# Ignore errors during cleanup
pass

View File

@@ -0,0 +1,203 @@
"""Test runner for orchestrating DVT test execution.
This module provides the TestRunner class, which coordinates test execution,
manages test lifecycle, and ensures proper logging and error handling.
"""
import json
import traceback
from typing import Any
from uuid import UUID
from py_dvt_ate.data.models import TestStatus
from py_dvt_ate.data.repository import ITestRepository
from py_dvt_ate.framework.context import ITest, TestContext
from py_dvt_ate.framework.limits import evaluate_results
from py_dvt_ate.framework.logger import TestLogger
from py_dvt_ate.instruments.factory import InstrumentSet
class TestRunner:
"""Orchestrates DVT test execution.
The test runner manages the complete test lifecycle:
1. Creates a test run record in the repository
2. Sets up logging and context
3. Executes the test with proper error handling
4. Evaluates results against limits
5. Updates final status and flushes data
Attributes:
repository: Data repository for persisting test results.
Example:
runner = TestRunner(repository)
instruments = factory.create(config)
run_id = runner.run_test(
test=TempCoTest(),
instruments=instruments,
config={"temp_points": [-40, 25, 85]},
operator="alice@example.com"
)
"""
def __init__(self, repository: ITestRepository):
"""Initialise test runner.
Args:
repository: Repository for persisting test data.
"""
self.repository = repository
def run_test(
self,
test: ITest,
instruments: InstrumentSet,
config: dict[str, Any] | None = None,
operator: str | None = None,
description: str | None = None,
) -> UUID:
"""Run a DVT test with full lifecycle management.
Creates a test run, executes the test with proper error handling,
evaluates results, and updates final status. All measurements and
results are persisted to the repository.
Args:
test: Test instance to execute (implements ITest).
instruments: Instrument set for test to use.
config: Optional test-specific configuration dictionary.
operator: Optional operator identifier (e.g., email address).
description: Optional human-readable test run description.
Returns:
UUID of the test run. Can be used to retrieve results later.
Raises:
Exception: Only if repository operations fail. Test execution
errors are caught and recorded as ERROR status.
Example:
run_id = runner.run_test(
test=TempCoTest(),
instruments=instruments,
config={"temp_points": [-40, 25, 85]},
operator="alice@example.com",
description="Characterisation run #42"
)
print(f"Test run ID: {run_id}")
"""
config = config or {}
# Create test run record
run_id = self.repository.create_run(
test_name=test.name,
config=config,
operator=operator,
description=description or test.description,
)
# Create logger for this run
logger = TestLogger(run_id=run_id, repository=self.repository)
# Create test context
context = TestContext(
run_id=run_id,
instruments=instruments,
logger=logger,
config=config,
)
# Update status to running
self.repository.update_run_status(run_id, TestStatus.RUNNING)
# Execute test with error handling
try:
logger.log_event(f"Starting test: {test.name}", level="INFO")
logger.log_event(f"Description: {test.description}", level="INFO")
# Log configuration
if config:
config_str = json.dumps(config, indent=2)
logger.log_event(f"Configuration:\n{config_str}", level="DEBUG")
# Execute the test
status = test.execute(context)
# Flush any buffered measurements
logger.flush()
# Evaluate results if test didn't explicitly set status
if status == TestStatus.RUNNING:
results = self.repository.get_results(run_id)
status = evaluate_results(results)
logger.log_event(
f"Test completed. Evaluated {len(results)} results: {status.value}",
level="INFO",
)
# Update final status
self.repository.complete_run(run_id, status)
logger.log_event(f"Test finished with status: {status.value}", level="INFO")
except KeyboardInterrupt:
# User interrupted - mark as error but don't swallow interrupt
logger.log_event("Test interrupted by user", level="WARNING")
logger.flush()
self.repository.complete_run(run_id, TestStatus.ERROR)
raise
except Exception as e:
# Test execution error - log and mark as ERROR
error_msg = f"Test execution failed: {e}"
logger.log_event(error_msg, level="ERROR")
logger.log_event(traceback.format_exc(), level="DEBUG")
logger.flush()
self.repository.complete_run(run_id, TestStatus.ERROR)
logger.log_event("Test finished with status: ERROR", level="INFO")
return run_id
def run_tests(
self,
tests: list[ITest],
instruments: InstrumentSet,
config: dict[str, Any] | None = None,
operator: str | None = None,
) -> list[UUID]:
"""Run multiple tests sequentially.
Convenience method for running a suite of tests. Each test is run
independently with its own test run record. If one test fails, the
remaining tests still execute.
Args:
tests: List of test instances to execute.
instruments: Instrument set shared by all tests.
config: Optional configuration applied to all tests.
operator: Optional operator identifier.
Returns:
List of test run UUIDs in execution order.
Example:
run_ids = runner.run_tests(
tests=[TempCoTest(), LoadRegTest(), LineRegTest()],
instruments=instruments,
config={"common_setting": 42},
operator="alice@example.com"
)
for run_id in run_ids:
run = repository.get_run(run_id)
print(f"{run.test_name}: {run.status.value}")
"""
run_ids = []
for test in tests:
run_id = self.run_test(
test=test,
instruments=instruments,
config=config,
operator=operator,
)
run_ids.append(run_id)
return run_ids

View File

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

View File

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

View File

@@ -1 +1,29 @@
"""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
"""
from py_dvt_ate.instruments.factory import (
InstrumentConfig,
InstrumentFactory,
InstrumentSet,
)
from py_dvt_ate.instruments.interfaces import (
IMultimeter,
IPowerSupply,
IThermalChamber,
)
__all__ = [
"IThermalChamber",
"IPowerSupply",
"IMultimeter",
"InstrumentSet",
"InstrumentConfig",
"InstrumentFactory",
]

View File

@@ -0,0 +1,17 @@
"""SCPI driver implementations for lab instruments.
Each driver translates high-level operations into SCPI commands
and handles responses from instruments.
"""
from py_dvt_ate.instruments.drivers.base import BaseDriver
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
__all__ = [
"BaseDriver",
"ThermalChamberDriver",
"PowerSupplyDriver",
"MultimeterDriver",
]

View File

@@ -0,0 +1,197 @@
"""Base class for SCPI instrument drivers.
This module provides the foundation for implementing client-side instrument
drivers that communicate via SCPI commands over a transport layer.
"""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from py_dvt_ate.instruments.transport.base import Transport
class BaseDriver:
"""Base class for SCPI instrument drivers.
Provides common functionality for communicating with instruments via
SCPI commands. Subclasses implement instrument-specific command methods.
All drivers depend on a Transport instance for low-level communication.
Attributes:
transport: The transport layer for communication.
"""
def __init__(self, transport: "Transport") -> None:
"""Initialise the driver with a transport layer.
Args:
transport: Transport instance for communication (TCP, VISA, etc.).
"""
self.transport = transport
def connect(self) -> None:
"""Establish connection to the instrument.
Raises:
ConnectionError: If connection fails.
"""
self.transport.connect()
def disconnect(self) -> None:
"""Close connection to the instrument.
Safe to call multiple times (idempotent).
"""
self.transport.disconnect()
@property
def is_connected(self) -> bool:
"""Check if connection is active.
Returns:
True if connected, False otherwise.
"""
return self.transport.is_connected
def write(self, command: str) -> None:
"""Send a SCPI command to the instrument.
Args:
command: SCPI command string (without terminator).
Raises:
ConnectionError: If not connected.
IOError: If write fails.
"""
self.transport.write(command)
def query(self, command: str, timeout: float | None = None) -> str:
"""Send a SCPI query and read the response.
Args:
command: SCPI query string (without terminator).
timeout: Read timeout in seconds. None uses default.
Returns:
Response string from instrument.
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If communication fails.
"""
return self.transport.query(command, timeout)
def query_float(self, command: str, timeout: float | None = None) -> float:
"""Send a SCPI query and parse response as float.
Args:
command: SCPI query string.
timeout: Read timeout in seconds.
Returns:
Parsed floating-point value.
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If communication fails.
ValueError: If response cannot be parsed as float.
"""
response = self.query(command, timeout)
try:
return float(response.strip())
except ValueError as err:
raise ValueError(f"Cannot parse '{response}' as float") from err
def query_int(self, command: str, timeout: float | None = None) -> int:
"""Send a SCPI query and parse response as integer.
Args:
command: SCPI query string.
timeout: Read timeout in seconds.
Returns:
Parsed integer value.
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If communication fails.
ValueError: If response cannot be parsed as integer.
"""
response = self.query(command, timeout)
try:
return int(response.strip())
except ValueError as err:
raise ValueError(f"Cannot parse '{response}' as int") from err
def query_bool(self, command: str, timeout: float | None = None) -> bool:
"""Send a SCPI query and parse response as boolean.
Interprets "1", "ON", "TRUE" as True; "0", "OFF", "FALSE" as False.
Args:
command: SCPI query string.
timeout: Read timeout in seconds.
Returns:
Parsed boolean value.
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If communication fails.
ValueError: If response cannot be parsed as boolean.
"""
response = self.query(command, timeout).strip().upper()
if response in ("1", "ON", "TRUE"):
return True
if response in ("0", "OFF", "FALSE"):
return False
raise ValueError(f"Cannot parse '{response}' as bool")
def identify(self) -> str:
"""Query instrument identification (*IDN?).
Returns:
Identification string in format:
"Manufacturer,Model,SerialNumber,FirmwareVersion"
Raises:
ConnectionError: If not connected.
IOError: If communication fails.
"""
return self.query("*IDN?")
def reset(self) -> None:
"""Reset instrument to default state (*RST).
Raises:
ConnectionError: If not connected.
IOError: If communication fails.
"""
self.write("*RST")
def clear_status(self) -> None:
"""Clear instrument status (*CLS).
Raises:
ConnectionError: If not connected.
IOError: If communication fails.
"""
self.write("*CLS")
def operation_complete(self) -> bool:
"""Query operation complete status (*OPC?).
Returns:
True if operation complete.
Raises:
ConnectionError: If not connected.
IOError: If communication fails.
"""
response = self.query("*OPC?")
return response.strip() == "1"

View File

@@ -0,0 +1,142 @@
"""Thermal chamber SCPI driver.
This module implements a client-side driver for thermal chambers that
communicate via SCPI commands.
"""
import time
from py_dvt_ate.instruments.drivers.base import BaseDriver
from py_dvt_ate.instruments.interfaces import IThermalChamber
class ThermalChamberDriver(BaseDriver, IThermalChamber):
"""SCPI driver for thermal chambers.
Provides high-level Python API for controlling thermal chambers via
SCPI commands. Implements the IThermalChamber interface.
SCPI Commands Used:
TEMP:SETPOINT <value> - Set target temperature (°C)
TEMP:SETPOINT? - Query current setpoint
TEMP:ACTUAL? - Query actual chamber temperature
TEMP:STAB? - Query stability (1=stable, 0=settling)
TEMP:RAMP <rate> - Set temperature ramp rate (°C/min)
TEMP:RAMP? - Query ramp rate
Example:
>>> transport = TCPTransport("localhost", 5001)
>>> chamber = ThermalChamberDriver(transport)
>>> chamber.connect()
>>> chamber.set_temperature(85.0)
>>> chamber.wait_until_stable(timeout=600.0)
>>> temp = chamber.get_temperature()
"""
def set_temperature(self, setpoint: float) -> None:
"""Set the chamber temperature setpoint.
Args:
setpoint: Target temperature in degrees Celsius.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
self.write(f"TEMP:SETPOINT {setpoint:.2f}")
def get_temperature(self) -> float:
"""Get the actual chamber temperature.
Returns:
Current chamber temperature in degrees Celsius.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("TEMP:ACTUAL?")
def get_setpoint(self) -> float:
"""Get the current temperature setpoint.
Returns:
Current setpoint in degrees Celsius.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("TEMP:SETPOINT?")
def is_stable(self) -> bool:
"""Check if chamber temperature is stable.
Temperature is considered stable when it has settled within
the instrument's configured stability threshold of the setpoint.
Returns:
True if temperature is stable, False if still settling.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_bool("TEMP:STAB?")
def wait_until_stable(
self, timeout: float = 300.0, poll_interval: float = 1.0
) -> bool:
"""Wait until chamber temperature stabilises.
Polls the stability status at regular intervals until stable
or timeout is reached.
Args:
timeout: Maximum time to wait in seconds. Default 300s (5 minutes).
poll_interval: Time between stability checks in seconds. Default 1s.
Returns:
True if temperature stabilised within timeout, False if timed out.
Raises:
ConnectionError: If not connected.
IOError: If communication fails.
ValueError: If timeout or poll_interval are negative.
"""
if timeout < 0:
raise ValueError("Timeout must be non-negative")
if poll_interval <= 0:
raise ValueError("Poll interval must be positive")
start_time = time.time()
while time.time() - start_time < timeout:
if self.is_stable():
return True
time.sleep(poll_interval)
return False
def set_ramp_rate(self, rate: float) -> None:
"""Set the temperature ramp rate.
Args:
rate: Ramp rate in degrees Celsius per minute.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
self.write(f"TEMP:RAMP {rate:.2f}")
def get_ramp_rate(self) -> float:
"""Get the current temperature ramp rate.
Returns:
Ramp rate in degrees Celsius per minute.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("TEMP:RAMP?")

View File

@@ -0,0 +1,158 @@
"""Multimeter SCPI driver.
This module implements a client-side driver for digital multimeters
that communicate via SCPI commands.
"""
from py_dvt_ate.instruments.drivers.base import BaseDriver
from py_dvt_ate.instruments.interfaces import IMultimeter
class MultimeterDriver(BaseDriver, IMultimeter):
"""SCPI driver for digital multimeters.
Provides high-level Python API for making measurements with DMMs via
SCPI commands. Implements the IMultimeter interface.
SCPI Commands Used:
MEAS:VOLT:DC? - Measure DC voltage
MEAS:CURR:DC? - Measure DC current
CONF:VOLT:DC - Configure for DC voltage measurement
CONF:CURR:DC - Configure for DC current measurement
CONF? - Query current configuration
READ? - Take measurement with current configuration
Example:
>>> transport = TCPTransport("localhost", 5003)
>>> dmm = MultimeterDriver(transport)
>>> dmm.connect()
>>> voltage = dmm.measure_dc_voltage()
>>> current = dmm.measure_dc_current()
"""
def measure_dc_voltage(self, range: str = "AUTO") -> float:
"""Measure DC voltage.
Configures the meter for DC voltage and takes a measurement.
Args:
range: Measurement range. Default "AUTO" for auto-ranging.
Note: Range parameter currently not supported by simulator.
Returns:
Measured voltage in volts.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
# Note: Range parameter not yet implemented in virtual instrument
return self.query_float("MEAS:VOLT:DC?")
def measure_dc_current(self, range: str = "AUTO") -> float:
"""Measure DC current.
Configures the meter for DC current and takes a measurement.
Args:
range: Measurement range. Default "AUTO" for auto-ranging.
Note: Range parameter currently not supported by simulator.
Returns:
Measured current in amps.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
# Note: Range parameter not yet implemented in virtual instrument
return self.query_float("MEAS:CURR:DC?")
def measure_resistance(self, range: str = "AUTO") -> float:
"""Measure resistance.
Configures the meter for resistance and takes a measurement.
Args:
range: Measurement range. Default "AUTO" for auto-ranging.
Returns:
Measured resistance in ohms.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
NotImplementedError: If instrument does not support resistance.
"""
# Note: Resistance measurement not yet implemented in virtual instrument
raise NotImplementedError(
"Resistance measurement not yet supported by virtual instrument"
)
def set_integration_time(self, nplc: float) -> None:
"""Set the integration time.
Args:
nplc: Integration time in number of power line cycles (NPLC).
Typical values: 0.02, 0.2, 1, 10, 100.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
NotImplementedError: If instrument does not support integration time.
"""
# Note: Integration time not yet implemented in virtual instrument
raise NotImplementedError(
"Integration time setting not yet supported by virtual instrument"
)
def configure_dc_voltage(self) -> None:
"""Configure meter for DC voltage measurement.
Sets the measurement function without taking a measurement.
Use read() to take a measurement after configuring.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
self.write("CONF:VOLT:DC")
def configure_dc_current(self) -> None:
"""Configure meter for DC current measurement.
Sets the measurement function without taking a measurement.
Use read() to take a measurement after configuring.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
self.write("CONF:CURR:DC")
def get_configuration(self) -> str:
"""Get the current measurement configuration.
Returns:
Configuration string (e.g., "VOLT:DC", "CURR:DC").
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query("CONF?").strip('"')
def read(self) -> float:
"""Take a measurement using the current configuration.
Must call configure_dc_voltage() or configure_dc_current() first
to set the measurement function.
Returns:
Measured value (voltage in V or current in A).
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("READ?")

View File

@@ -0,0 +1,153 @@
"""Power supply SCPI driver.
This module implements a client-side driver for programmable power supplies
that communicate via SCPI commands.
"""
from py_dvt_ate.instruments.drivers.base import BaseDriver
from py_dvt_ate.instruments.interfaces import IPowerSupply
class PowerSupplyDriver(BaseDriver, IPowerSupply):
"""SCPI driver for programmable power supplies.
Provides high-level Python API for controlling power supplies via
SCPI commands. Implements the IPowerSupply interface.
Note: This driver assumes a single-channel instrument. The channel
parameter is accepted for interface compatibility but currently ignored.
SCPI Commands Used:
VOLT <value> - Set output voltage (V)
VOLT? - Query voltage setpoint
CURR <value> - Set current limit (A)
CURR? - Query current limit
OUTP <ON|OFF|1|0> - Enable/disable output
OUTP? - Query output state (1=on, 0=off)
MEAS:VOLT? - Measure actual output voltage
MEAS:CURR? - Measure actual output current
Example:
>>> transport = TCPTransport("localhost", 5002)
>>> psu = PowerSupplyDriver(transport)
>>> psu.connect()
>>> psu.set_voltage(1, 3.3)
>>> psu.set_current_limit(1, 0.5)
>>> psu.enable_output(1, True)
>>> voltage = psu.measure_voltage(1)
"""
def set_voltage(self, channel: int, voltage: float) -> None:
"""Set the output voltage setpoint.
Args:
channel: Channel number (currently ignored, single channel assumed).
voltage: Target voltage in volts.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
self.write(f"VOLT {voltage:.3f}")
def get_voltage(self, channel: int) -> float:
"""Get the voltage setpoint.
Args:
channel: Channel number (currently ignored, single channel assumed).
Returns:
Current voltage setpoint in volts.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("VOLT?")
def set_current_limit(self, channel: int, current: float) -> None:
"""Set the current limit.
Args:
channel: Channel number (currently ignored, single channel assumed).
current: Current limit in amps.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
self.write(f"CURR {current:.3f}")
def get_current_limit(self, channel: int) -> float:
"""Get the current limit.
Args:
channel: Channel number (currently ignored, single channel assumed).
Returns:
Current limit in amps.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("CURR?")
def measure_voltage(self, channel: int) -> float:
"""Measure the actual output voltage.
Args:
channel: Channel number (currently ignored, single channel assumed).
Returns:
Measured voltage in volts.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("MEAS:VOLT?")
def measure_current(self, channel: int) -> float:
"""Measure the actual output current.
Args:
channel: Channel number (currently ignored, single channel assumed).
Returns:
Measured current in amps.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_float("MEAS:CURR?")
def enable_output(self, channel: int, enable: bool) -> None:
"""Enable or disable the output.
Args:
channel: Channel number (currently ignored, single channel assumed).
enable: True to enable output, False to disable.
Raises:
ConnectionError: If not connected.
IOError: If command fails.
"""
state = "ON" if enable else "OFF"
self.write(f"OUTP {state}")
def is_output_enabled(self, channel: int) -> bool:
"""Check if output is enabled.
Args:
channel: Channel number (currently ignored, single channel assumed).
Returns:
True if output is enabled, False if disabled.
Raises:
ConnectionError: If not connected.
IOError: If query fails.
"""
return self.query_bool("OUTP?")

View File

@@ -0,0 +1,176 @@
"""Instrument factory for creating configured instrument sets.
This module provides a factory pattern for creating sets of instruments
based on configuration. It abstracts away the choice between simulated
and real hardware, allowing test code to be written once and run against
either backend.
"""
from dataclasses import dataclass
from typing import Literal
from py_dvt_ate.instruments.interfaces import IMultimeter, IPowerSupply, IThermalChamber
@dataclass
class InstrumentSet:
"""Container for a complete set of instruments.
Holds all instruments needed for DVT testing. All instruments implement
the interface protocols (IThermalChamber, IPowerSupply, IMultimeter),
allowing them to be simulated or real hardware.
Attributes:
chamber: Thermal chamber for temperature control.
psu: Programmable power supply for DUT power.
dmm: Digital multimeter for precision measurements.
"""
chamber: IThermalChamber
psu: IPowerSupply
dmm: IMultimeter
@dataclass
class InstrumentConfig:
"""Configuration for instrument connections.
Defines how to connect to instruments. The backend determines whether
to use simulated instruments (TCP connections to virtual instruments)
or real hardware (PyVISA connections).
Attributes:
backend: "simulator" for virtual instruments, "pyvisa" for real hardware.
Simulator Settings:
simulator_host: Hostname/IP of simulation server. Default "localhost".
chamber_port: TCP port for thermal chamber simulator. Default 5001.
psu_port: TCP port for power supply simulator. Default 5002.
dmm_port: TCP port for multimeter simulator. Default 5003.
PyVISA Settings (for real hardware):
chamber_visa: VISA resource string for thermal chamber (e.g., "TCPIP::192.168.1.10::INSTR").
psu_visa: VISA resource string for power supply.
dmm_visa: VISA resource string for multimeter.
"""
backend: Literal["simulator", "pyvisa"]
# Simulator settings
simulator_host: str = "localhost"
chamber_port: int = 5001
psu_port: int = 5002
dmm_port: int = 5003
# PyVISA settings (for real hardware)
chamber_visa: str | None = None
psu_visa: str | None = None
dmm_visa: str | None = None
class InstrumentFactory:
"""Factory for creating instrument sets from configuration.
This factory encapsulates the creation logic for instrument sets,
hiding the complexity of instantiating transports and drivers based
on the chosen backend.
Example:
>>> config = InstrumentConfig(backend="simulator")
>>> instruments = InstrumentFactory.create(config)
>>> instruments.chamber.set_temperature(85.0)
>>> instruments.psu.set_voltage(1, 3.3)
>>> voltage = instruments.dmm.measure_dc_voltage()
"""
@staticmethod
def create(config: InstrumentConfig) -> InstrumentSet:
"""Create instrument set based on configuration.
Args:
config: Configuration specifying backend and connection details.
Returns:
InstrumentSet containing all configured instruments.
Raises:
ValueError: If backend is unknown or configuration is invalid.
ConnectionError: If unable to connect to instruments.
"""
if config.backend == "simulator":
return InstrumentFactory._create_simulated(config)
elif config.backend == "pyvisa":
return InstrumentFactory._create_pyvisa(config)
else:
raise ValueError(f"Unknown backend: {config.backend}")
@staticmethod
def _create_simulated(config: InstrumentConfig) -> InstrumentSet:
"""Create simulated instruments connected via TCP.
Creates TCP transports for each virtual instrument and wraps them
in SCPI drivers. The simulation server must be running and listening
on the configured ports.
Args:
config: Configuration with simulator_host and port settings.
Returns:
InstrumentSet with simulated instruments.
Raises:
ConnectionError: If unable to connect to simulation server.
"""
from py_dvt_ate.instruments.drivers.chamber import ThermalChamberDriver
from py_dvt_ate.instruments.drivers.multimeter import MultimeterDriver
from py_dvt_ate.instruments.drivers.power_supply import PowerSupplyDriver
from py_dvt_ate.instruments.transport.tcp import TCPTransport
# Create transports for each instrument
chamber_transport = TCPTransport(config.simulator_host, config.chamber_port)
psu_transport = TCPTransport(config.simulator_host, config.psu_port)
dmm_transport = TCPTransport(config.simulator_host, config.dmm_port)
# Wrap transports in drivers
return InstrumentSet(
chamber=ThermalChamberDriver(chamber_transport),
psu=PowerSupplyDriver(psu_transport),
dmm=MultimeterDriver(dmm_transport),
)
@staticmethod
def _create_pyvisa(config: InstrumentConfig) -> InstrumentSet:
"""Create PyVISA instruments for real hardware.
Creates VISA transports for each real instrument and wraps them
in SCPI drivers. Requires PyVISA to be installed and VISA resource
strings to be configured.
Args:
config: Configuration with chamber_visa, psu_visa, dmm_visa settings.
Returns:
InstrumentSet with real hardware instruments.
Raises:
NotImplementedError: PyVISA backend not yet implemented.
ValueError: If required VISA resource strings are missing.
"""
# Future implementation would use pyvisa.ResourceManager
# to create VISA transports:
#
# import pyvisa
# from py_dvt_ate.instruments.transport.visa import VISATransport
#
# rm = pyvisa.ResourceManager()
# chamber_transport = VISATransport(rm.open_resource(config.chamber_visa))
# psu_transport = VISATransport(rm.open_resource(config.psu_visa))
# dmm_transport = VISATransport(rm.open_resource(config.dmm_visa))
#
# return InstrumentSet(
# chamber=ThermalChamberDriver(chamber_transport),
# psu=PowerSupplyDriver(psu_transport),
# dmm=MultimeterDriver(dmm_transport),
# )
raise NotImplementedError("PyVISA backend not yet implemented")

View File

@@ -0,0 +1,362 @@
"""Instrument interface protocols.
This module defines the Hardware Abstraction Layer (HAL) interfaces for all
laboratory instruments used in DVT testing. These protocols allow test code
to be written against abstract interfaces rather than concrete implementations,
enabling seamless switching between simulated and real hardware.
The interfaces use ABC (Abstract Base Classes) for maximum type safety and
explicit interface implementation. All drivers must inherit from these base
classes and implement all abstract methods.
"""
from abc import ABC, abstractmethod
class IThermalChamber(ABC):
"""Hardware abstraction for thermal chambers.
Defines the interface for controlling environmental temperature during
thermal characterisation tests. Implementations may be virtual instruments
(simulators) or real hardware drivers.
Temperature units are always degrees Celsius.
"""
@abstractmethod
def set_temperature(self, setpoint: float) -> None:
"""Set the chamber temperature setpoint.
Args:
setpoint: Target temperature in degrees Celsius.
Raises:
ConnectionError: If not connected to instrument.
IOError: If command fails or instrument reports error.
"""
pass
@abstractmethod
def get_temperature(self) -> float:
"""Get the actual chamber temperature.
Returns:
Current chamber air temperature in degrees Celsius.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
"""
pass
@abstractmethod
def get_setpoint(self) -> float:
"""Get the current temperature setpoint.
Returns:
Current target temperature in degrees Celsius.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
"""
pass
@abstractmethod
def is_stable(self) -> bool:
"""Check if chamber temperature is stable.
Temperature is considered stable when it has settled within
the instrument's configured stability threshold of the setpoint
for a minimum dwell time.
Returns:
True if temperature is stable, False if still settling.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
"""
pass
@abstractmethod
def wait_until_stable(
self, timeout: float = 300.0, poll_interval: float = 1.0
) -> bool:
"""Wait until chamber temperature stabilises.
Polls the stability status at regular intervals until stable
or timeout is reached. This is a blocking call.
Args:
timeout: Maximum time to wait in seconds. Default 300s (5 minutes).
poll_interval: Time between stability checks in seconds. Default 1s.
Returns:
True if temperature stabilised within timeout, False if timed out.
Raises:
ConnectionError: If not connected to instrument.
IOError: If communication fails.
ValueError: If timeout or poll_interval are invalid.
"""
pass
@abstractmethod
def set_ramp_rate(self, rate: float) -> None:
"""Set the temperature ramp rate.
Controls how quickly the chamber changes temperature when moving
to a new setpoint. Slower ramp rates reduce thermal shock to DUT.
Args:
rate: Ramp rate in degrees Celsius per minute.
Raises:
ConnectionError: If not connected to instrument.
IOError: If command fails or instrument reports error.
ValueError: If rate is negative or exceeds instrument limits.
"""
pass
class IPowerSupply(ABC):
"""Hardware abstraction for programmable power supplies.
Defines the interface for controlling DC power supplies during electrical
characterisation tests. Implementations may be virtual instruments
(simulators) or real hardware drivers.
Voltage units are always volts (V).
Current units are always amps (A).
"""
@abstractmethod
def set_voltage(self, channel: int, voltage: float) -> None:
"""Set the output voltage setpoint.
Args:
channel: Output channel number (1-based indexing).
voltage: Target voltage in volts.
Raises:
ConnectionError: If not connected to instrument.
IOError: If command fails or instrument reports error.
ValueError: If channel is invalid or voltage out of range.
"""
pass
@abstractmethod
def get_voltage(self, channel: int) -> float:
"""Get the voltage setpoint.
Returns the programmed voltage, not the measured output voltage.
Use measure_voltage() to get the actual output voltage.
Args:
channel: Output channel number (1-based indexing).
Returns:
Current voltage setpoint in volts.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If channel is invalid.
"""
pass
@abstractmethod
def set_current_limit(self, channel: int, current: float) -> None:
"""Set the current limit.
The supply will operate in constant voltage mode until output current
reaches this limit, then transition to constant current mode.
Args:
channel: Output channel number (1-based indexing).
current: Current limit in amps.
Raises:
ConnectionError: If not connected to instrument.
IOError: If command fails or instrument reports error.
ValueError: If channel is invalid or current out of range.
"""
pass
@abstractmethod
def get_current_limit(self, channel: int) -> float:
"""Get the current limit.
Args:
channel: Output channel number (1-based indexing).
Returns:
Current limit in amps.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If channel is invalid.
"""
pass
@abstractmethod
def measure_voltage(self, channel: int) -> float:
"""Measure the actual output voltage.
Args:
channel: Output channel number (1-based indexing).
Returns:
Measured output voltage in volts.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If channel is invalid.
"""
pass
@abstractmethod
def measure_current(self, channel: int) -> float:
"""Measure the actual output current.
Args:
channel: Output channel number (1-based indexing).
Returns:
Measured output current in amps.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If channel is invalid.
"""
pass
@abstractmethod
def enable_output(self, channel: int, enable: bool) -> None:
"""Enable or disable the output.
Args:
channel: Output channel number (1-based indexing).
enable: True to enable output, False to disable.
Raises:
ConnectionError: If not connected to instrument.
IOError: If command fails or instrument reports error.
ValueError: If channel is invalid.
"""
pass
@abstractmethod
def is_output_enabled(self, channel: int) -> bool:
"""Check if output is enabled.
Args:
channel: Output channel number (1-based indexing).
Returns:
True if output is enabled, False if disabled.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If channel is invalid.
"""
pass
class IMultimeter(ABC):
"""Hardware abstraction for digital multimeters.
Defines the interface for making precision measurements with DMMs during
electrical characterisation tests. Implementations may be virtual instruments
(simulators) or real hardware drivers.
Voltage units are always volts (V).
Current units are always amps (A).
Resistance units are always ohms (Ω).
"""
@abstractmethod
def measure_dc_voltage(self, range: str = "AUTO") -> float:
"""Measure DC voltage.
Configures the meter for DC voltage and takes a measurement.
Args:
range: Measurement range. Default "AUTO" for auto-ranging.
Specific ranges depend on instrument capabilities.
Returns:
Measured voltage in volts.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If range is invalid for this instrument.
"""
pass
@abstractmethod
def measure_dc_current(self, range: str = "AUTO") -> float:
"""Measure DC current.
Configures the meter for DC current and takes a measurement.
Args:
range: Measurement range. Default "AUTO" for auto-ranging.
Specific ranges depend on instrument capabilities.
Returns:
Measured current in amps.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If range is invalid for this instrument.
"""
pass
@abstractmethod
def measure_resistance(self, range: str = "AUTO") -> float:
"""Measure resistance.
Configures the meter for resistance and takes a measurement.
Args:
range: Measurement range. Default "AUTO" for auto-ranging.
Specific ranges depend on instrument capabilities.
Returns:
Measured resistance in ohms.
Raises:
ConnectionError: If not connected to instrument.
IOError: If query fails or instrument reports error.
ValueError: If range is invalid for this instrument.
NotImplementedError: If instrument does not support resistance.
"""
pass
@abstractmethod
def set_integration_time(self, nplc: float) -> None:
"""Set the integration time.
Integration time affects measurement accuracy and speed. Higher
values (more power line cycles) provide better noise rejection
but take longer to measure.
Args:
nplc: Integration time in number of power line cycles (NPLC).
Typical values: 0.02, 0.2, 1, 10, 100.
Raises:
ConnectionError: If not connected to instrument.
IOError: If command fails or instrument reports error.
ValueError: If nplc is invalid for this instrument.
NotImplementedError: If instrument does not support integration time.
"""
pass

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,13 @@
"""Transport layer for instrument communication.
Provides connection abstractions for different backends:
- TCP server for hosting SCPI instruments
- TCP sockets (for simulation server)
- PyVISA (for real instruments)
"""
from py_dvt_ate.instruments.transport.base import Transport
from py_dvt_ate.instruments.transport.server import InstrumentServer, SCPIDevice
from py_dvt_ate.instruments.transport.tcp import TCPTransport
__all__ = ["Transport", "TCPTransport", "InstrumentServer", "SCPIDevice"]

View File

@@ -0,0 +1,93 @@
"""Base transport interface for instrument communication."""
from abc import ABC, abstractmethod
class Transport(ABC):
"""Abstract transport interface for instrument communication.
This abstract base class defines the interface that all transport
implementations (TCP, VISA, etc.) must implement. It provides basic
connection management and communication primitives for SCPI-based
instruments.
Implementations must inherit from this class and implement all abstract
methods.
"""
@abstractmethod
def connect(self) -> None:
"""Establish connection to instrument.
Raises:
ConnectionError: If connection fails.
"""
pass
@abstractmethod
def disconnect(self) -> None:
"""Close connection to instrument.
Should be idempotent - safe to call multiple times.
"""
pass
@abstractmethod
def write(self, command: str) -> None:
"""Send command to instrument.
Args:
command: SCPI command string to send (without terminator).
Raises:
ConnectionError: If not connected.
IOError: If write fails.
"""
pass
@abstractmethod
def read(self, timeout: float | None = None) -> str:
"""Read response from instrument.
Args:
timeout: Read timeout in seconds. None uses default.
Returns:
Response string from instrument (without terminator).
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If read fails.
"""
pass
@abstractmethod
def query(self, command: str, timeout: float | None = None) -> str:
"""Send command and read response.
Convenience method combining write() and read().
Args:
command: SCPI command string to send.
timeout: Read timeout in seconds. None uses default.
Returns:
Response string from instrument.
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If communication fails.
"""
pass
@property
@abstractmethod
def is_connected(self) -> bool:
"""Check if connection is active.
Returns:
True if connected, False otherwise.
"""
pass

View File

@@ -0,0 +1,240 @@
"""Async TCP server for exposing instruments over network.
This module provides the InstrumentServer class that hosts SCPI
instruments over TCP, allowing client applications to communicate using
standard SCPI commands over a network connection.
This is a general-purpose server that works with any object implementing
the SCPIDevice protocol (having a process(command) -> str method).
"""
from __future__ import annotations
import asyncio
import logging
from functools import partial
from typing import Protocol, runtime_checkable
__all__ = ["InstrumentServer", "SCPIDevice"]
logger = logging.getLogger(__name__)
@runtime_checkable
class SCPIDevice(Protocol):
"""Protocol for SCPI-compatible devices.
Any object with a process method matching this signature can be
served by InstrumentServer.
"""
def process(self, command: str) -> str:
"""Process a SCPI command and return the response.
Args:
command: SCPI command string to process.
Returns:
Response string (may be empty for commands with no response).
"""
...
class InstrumentServer:
"""Async TCP server hosting SCPI instruments.
Each instrument is assigned a port. Clients connect via TCP and send
SCPI commands as newline-terminated strings. Responses are also
newline-terminated.
This server can host any device implementing the SCPIDevice protocol,
including both virtual instruments (simulators) and adapters for
real hardware.
Attributes:
host: Host address to bind to.
"""
def __init__(self, host: str = "127.0.0.1") -> None:
"""Initialise the instrument server.
Args:
host: Host address to bind to. Defaults to localhost.
"""
self._host = host
self._instruments: dict[int, SCPIDevice] = {}
self._servers: list[asyncio.Server] = []
self._running = False
@property
def host(self) -> str:
"""Get the host address."""
return self._host
@property
def is_running(self) -> bool:
"""Check if server is currently running."""
return self._running
def register_instrument(self, port: int, instrument: SCPIDevice) -> None:
"""Register an instrument to be served on a specific port.
Args:
port: TCP port number to serve the instrument on.
instrument: SCPI device to serve (any object with process method).
Raises:
ValueError: If port is already registered.
RuntimeError: If server is already running.
"""
if self._running:
raise RuntimeError("Cannot register instruments while server is running")
if port in self._instruments:
raise ValueError(f"Port {port} is already registered")
self._instruments[port] = instrument
logger.info(
"Registered %s on port %d",
instrument.__class__.__name__,
port,
)
def get_instrument(self, port: int) -> SCPIDevice | None:
"""Get the instrument registered on a port.
Args:
port: Port number to look up.
Returns:
Registered instrument, or None if port not registered.
"""
return self._instruments.get(port)
@property
def registered_ports(self) -> list[int]:
"""Get list of registered port numbers."""
return list(self._instruments.keys())
async def start(self) -> None:
"""Start the server and begin listening on all registered ports.
Creates a TCP server for each registered instrument port.
Raises:
RuntimeError: If server is already running or no instruments registered.
"""
if self._running:
raise RuntimeError("Server is already running")
if not self._instruments:
raise RuntimeError("No instruments registered")
self._running = True
for port, instrument in self._instruments.items():
handler = partial(self._handle_client, instrument=instrument, port=port)
server = await asyncio.start_server(
handler,
self._host,
port,
reuse_address=True,
)
self._servers.append(server)
logger.info(
"Started server for %s on %s:%d",
instrument.__class__.__name__,
self._host,
port,
)
async def stop(self) -> None:
"""Stop the server and close all connections."""
if not self._running:
return
for server in self._servers:
server.close()
await server.wait_closed()
self._servers.clear()
self._running = False
logger.info("Server stopped")
async def serve_forever(self) -> None:
"""Start the server and run until cancelled.
This is a convenience method that starts the server and blocks
until the server is stopped or cancelled.
"""
await self.start()
try:
# Keep running until cancelled
await asyncio.gather(
*[server.serve_forever() for server in self._servers]
)
finally:
await self.stop()
async def _handle_client(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
instrument: SCPIDevice,
port: int,
) -> None:
"""Handle a client connection.
Reads SCPI commands (newline-terminated), processes them through
the instrument, and sends back responses (newline-terminated).
Args:
reader: Stream reader for incoming data.
writer: Stream writer for outgoing data.
instrument: The instrument to process commands.
port: Port number for logging.
"""
addr = writer.get_extra_info("peername")
logger.info("Client connected to port %d from %s", port, addr)
try:
while True:
# Read until newline (SCPI line terminator)
data = await reader.readline()
if not data:
# Client disconnected
break
# Decode and strip whitespace
command = data.decode("utf-8").strip()
if not command:
continue
logger.debug("Port %d received: %s", port, command)
# Process command through instrument
response = instrument.process(command)
# Send response with newline terminator (only if non-empty)
# Per SCPI protocol: commands that complete successfully without
# output do not send a response. Only queries and errors respond.
if response:
writer.write(f"{response}\n".encode())
await writer.drain()
logger.debug("Port %d sent: %s", port, response)
except asyncio.CancelledError:
logger.debug("Client handler cancelled for port %d", port)
except ConnectionResetError:
logger.debug("Client connection reset on port %d", port)
except Exception as e:
logger.error("Error handling client on port %d: %s", port, e)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
logger.info("Client disconnected from port %d", port)

View File

@@ -0,0 +1,195 @@
"""TCP socket transport for instrument communication."""
import socket
from typing import Any
from py_dvt_ate.instruments.transport.base import Transport
class TCPTransport(Transport):
"""TCP socket transport implementation.
Implements the Transport interface for communicating with SCPI
instruments over TCP/IP using newline-terminated messages.
Attributes:
host: Hostname or IP address of the instrument.
port: TCP port number.
timeout: Default socket timeout in seconds.
"""
def __init__(
self,
host: str,
port: int,
timeout: float = 5.0,
encoding: str = "utf-8",
) -> None:
"""Initialise TCP transport.
Args:
host: Hostname or IP address.
port: TCP port number.
timeout: Default socket timeout in seconds.
encoding: Text encoding for commands and responses.
"""
self._host = host
self._port = port
self._timeout = timeout
self._encoding = encoding
self._socket: socket.socket | None = None
@property
def host(self) -> str:
"""Get the host address."""
return self._host
@property
def port(self) -> int:
"""Get the port number."""
return self._port
@property
def is_connected(self) -> bool:
"""Check if connection is active.
Returns:
True if connected, False otherwise.
"""
return self._socket is not None
def connect(self) -> None:
"""Establish connection to instrument.
Raises:
ConnectionError: If connection fails or already connected.
"""
if self.is_connected:
raise ConnectionError("Already connected")
try:
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(self._timeout)
self._socket.connect((self._host, self._port))
except OSError as err:
self._socket = None
raise ConnectionError(
f"Failed to connect to {self._host}:{self._port}: {err}"
) from err
def disconnect(self) -> None:
"""Close connection to instrument.
Safe to call multiple times (idempotent).
"""
if self._socket is not None:
try:
self._socket.close()
except OSError:
pass # Ignore errors during close
finally:
self._socket = None
def write(self, command: str) -> None:
"""Send command to instrument.
Commands are sent with newline terminator appended.
Args:
command: SCPI command string to send (without terminator).
Raises:
ConnectionError: If not connected.
IOError: If write fails.
"""
if not self.is_connected or self._socket is None:
raise ConnectionError("Not connected")
try:
message = f"{command}\n".encode(self._encoding)
self._socket.sendall(message)
except OSError as err:
raise OSError(f"Write failed: {err}") from err
def read(self, timeout: float | None = None) -> str:
"""Read response from instrument.
Reads until newline terminator is received.
Args:
timeout: Read timeout in seconds. None uses default.
Returns:
Response string from instrument (without terminator).
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If read fails.
"""
if not self.is_connected or self._socket is None:
raise ConnectionError("Not connected")
# Set timeout if specified
old_timeout = self._socket.gettimeout()
if timeout is not None:
self._socket.settimeout(timeout)
try:
# Read line by line (newline-terminated protocol)
response_bytes = b""
while True:
chunk = self._socket.recv(1)
if not chunk:
raise ConnectionError("Connection closed by remote host")
response_bytes += chunk
if chunk == b"\n":
break
# Decode and strip whitespace
return response_bytes.decode(self._encoding).strip()
except ConnectionError:
raise # Re-raise ConnectionError as-is
except TimeoutError as err:
raise TimeoutError("Read timeout") from err
except (OSError, UnicodeDecodeError) as err:
raise OSError(f"Read failed: {err}") from err
finally:
# Restore original timeout
if timeout is not None:
self._socket.settimeout(old_timeout)
def query(self, command: str, timeout: float | None = None) -> str:
"""Send command and read response.
Convenience method combining write() and read().
Args:
command: SCPI command string to send.
timeout: Read timeout in seconds. None uses default.
Returns:
Response string from instrument.
Raises:
ConnectionError: If not connected.
TimeoutError: If read times out.
IOError: If communication fails.
"""
self.write(command)
return self.read(timeout)
def __enter__(self) -> "TCPTransport":
"""Context manager entry."""
self.connect()
return self
def __exit__(self, *args: Any) -> None:
"""Context manager exit."""
self.disconnect()
def __repr__(self) -> str:
"""String representation."""
status = "connected" if self.is_connected else "disconnected"
return f"TCPTransport({self._host}:{self._port}, {status})"

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
"""Report generation module for py_dvt_ate.
This module provides automated PDF report generation from test results.
Reports include test metadata, results tables, pass/fail status, and charts.
Example usage:
>>> from uuid import UUID
>>> from py_dvt_ate.data.repository import SQLiteRepository
>>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig
>>>
>>> # Create repository and generator
>>> repo = SQLiteRepository("./data/py_dvt_ate.db")
>>> config = ReportConfig(company_name="My Company", include_charts=True)
>>> generator = ReportGenerator(repo, config)
>>>
>>> # Generate PDF report
>>> run_id = UUID("12345678-1234-1234-1234-123456789abc")
>>> pdf_path = generator.generate(run_id)
>>> print(f"Report saved to: {pdf_path}")
>>>
>>> # Or get PDF as bytes (for streaming downloads)
>>> pdf_bytes = generator.generate_bytes(run_id)
Classes:
ReportGenerator: Main class for generating PDF reports.
ReportConfig: Configuration options for report generation.
ReportData: Data container for report content.
Exceptions:
ReportingError: Base exception for reporting errors.
ReportGenerationError: General report generation failures.
TemplateRenderError: HTML template rendering failures.
PDFConversionError: HTML to PDF conversion failures.
ChartGenerationError: Chart generation failures.
"""
from py_dvt_ate.reporting.exceptions import (
ChartGenerationError,
PDFConversionError,
ReportGenerationError,
ReportingError,
TemplateRenderError,
)
from py_dvt_ate.reporting.generator import ReportGenerator
from py_dvt_ate.reporting.models import ReportConfig, ReportData
__all__ = [
# Main classes
"ReportGenerator",
"ReportConfig",
"ReportData",
# Exceptions
"ReportingError",
"ReportGenerationError",
"TemplateRenderError",
"PDFConversionError",
"ChartGenerationError",
]

View File

@@ -0,0 +1,5 @@
"""Chart generation for reports."""
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
__all__ = ["ChartGenerator"]

View File

@@ -0,0 +1,233 @@
"""Chart generation using matplotlib.
This module provides chart generation for test reports using matplotlib.
Charts are rendered to base64-encoded PNG images for embedding in HTML/PDF.
"""
import base64
from io import BytesIO
from typing import Any
import pandas as pd
from py_dvt_ate.data.models import TestResult, TestRun
from py_dvt_ate.reporting.exceptions import ChartGenerationError
class ChartGenerator:
"""Generates charts for test reports using matplotlib.
Charts are rendered with professional styling and returned as
base64-encoded PNG images suitable for embedding in HTML.
"""
def __init__(self, dpi: int = 150) -> None:
"""Initialise the chart generator.
Args:
dpi: Resolution for chart images (dots per inch).
"""
self.dpi = dpi
self._plt: tuple[Any, Any] | None = None
def _get_matplotlib(self) -> tuple[Any, Any]:
"""Lazy-load matplotlib to avoid import errors when not installed.
Returns:
Tuple of (pyplot module, matplotlib module).
Raises:
ChartGenerationError: If matplotlib is not installed.
"""
if self._plt is None:
try:
import matplotlib
matplotlib.use("Agg") # Non-interactive backend
import matplotlib.pyplot as plt
self._plt = (plt, matplotlib)
except ImportError as e:
msg = (
"matplotlib is required for chart generation. "
"Install it with: pip install py_dvt_ate[reports]"
)
raise ChartGenerationError(msg) from e
return self._plt
def _apply_style(self) -> None:
"""Apply professional styling to matplotlib charts."""
plt, _ = self._get_matplotlib()
plt.style.use("seaborn-v0_8-whitegrid")
plt.rcParams.update(
{
"font.family": "sans-serif",
"font.sans-serif": ["Helvetica", "Arial", "sans-serif"],
"font.size": 10,
"axes.titlesize": 12,
"axes.labelsize": 10,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"legend.fontsize": 9,
"figure.figsize": (8, 5),
"figure.dpi": self.dpi,
"axes.spines.top": False,
"axes.spines.right": False,
}
)
def _fig_to_base64(self, fig) -> str: # type: ignore[no-untyped-def]
"""Convert a matplotlib figure to base64-encoded PNG.
Args:
fig: Matplotlib figure object.
Returns:
Base64-encoded PNG image string.
"""
plt, _ = self._get_matplotlib()
buffer = BytesIO()
fig.savefig(buffer, format="png", dpi=self.dpi, bbox_inches="tight")
plt.close(fig)
buffer.seek(0)
return base64.b64encode(buffer.read()).decode("utf-8")
def generate_voltage_vs_temperature(
self, measurements: pd.DataFrame
) -> str:
"""Generate a voltage vs temperature chart.
Args:
measurements: DataFrame with 'temperature' and 'value' columns,
filtered to output voltage measurements.
Returns:
Base64-encoded PNG image string.
Raises:
ChartGenerationError: If chart generation fails.
"""
try:
plt, _ = self._get_matplotlib()
self._apply_style()
# Filter for output voltage measurements
voltage_data = measurements[
measurements["parameter"].str.contains("output_voltage", case=False)
].copy()
if voltage_data.empty:
msg = "No output voltage measurements found in data"
raise ChartGenerationError(msg)
# Group by temperature and get mean voltage at each point
grouped = voltage_data.groupby("temperature")["value"].mean().reset_index()
fig, ax = plt.subplots()
ax.plot(
grouped["temperature"],
grouped["value"],
marker="o",
linewidth=2,
markersize=6,
color="#2563eb",
)
ax.set_xlabel("Temperature (°C)")
ax.set_ylabel("Output Voltage (V)")
ax.set_title("Output Voltage vs Temperature")
ax.grid(True, alpha=0.3)
return self._fig_to_base64(fig)
except ChartGenerationError:
raise
except Exception as e:
msg = f"Failed to generate voltage vs temperature chart: {e}"
raise ChartGenerationError(msg) from e
def generate_results_bar_chart(self, results: list[TestResult]) -> str:
"""Generate a bar chart of test results.
Args:
results: List of test results.
Returns:
Base64-encoded PNG image string.
Raises:
ChartGenerationError: If chart generation fails.
"""
try:
plt, _ = self._get_matplotlib()
self._apply_style()
if not results:
msg = "No results to chart"
raise ChartGenerationError(msg)
# Prepare data
parameters = [r.parameter for r in results]
values = [r.value for r in results]
colours = ["#16a34a" if r.passed else "#dc2626" for r in results]
fig, ax = plt.subplots()
bars = ax.barh(parameters, values, color=colours)
# Add value labels
for bar, value in zip(bars, values, strict=False):
ax.text(
bar.get_width(),
bar.get_y() + bar.get_height() / 2,
f" {value:.4f}",
va="center",
fontsize=8,
)
ax.set_xlabel("Value")
ax.set_title("Test Results by Parameter")
ax.invert_yaxis()
return self._fig_to_base64(fig)
except ChartGenerationError:
raise
except Exception as e:
msg = f"Failed to generate results bar chart: {e}"
raise ChartGenerationError(msg) from e
def generate_all(
self,
run: TestRun,
results: list[TestResult],
measurements: pd.DataFrame | None,
) -> dict[str, str]:
"""Generate all applicable charts for a test run.
Args:
run: Test run metadata.
results: List of test results.
measurements: DataFrame of time-series measurements (optional).
Returns:
Dictionary mapping chart names to base64-encoded PNG images.
"""
charts: dict[str, str] = {}
# Try to generate voltage vs temperature chart if measurements available
if measurements is not None and not measurements.empty:
try:
charts["Voltage vs Temperature"] = self.generate_voltage_vs_temperature(
measurements
)
except ChartGenerationError:
pass # Skip if no voltage data
# Generate results bar chart if we have results
if results:
try:
charts["Results Summary"] = self.generate_results_bar_chart(results)
except ChartGenerationError:
pass # Skip if chart generation fails
return charts

View File

@@ -0,0 +1,41 @@
"""Exception classes for the reporting module.
This module defines a hierarchy of exceptions for report generation errors,
enabling specific error handling for different failure modes.
"""
class ReportingError(Exception):
"""Base exception for all reporting-related errors."""
class ReportGenerationError(ReportingError):
"""Raised when report generation fails.
This is the general error for failures during the report generation
process that don't fit into more specific categories.
"""
class TemplateRenderError(ReportingError):
"""Raised when HTML template rendering fails.
This typically indicates a problem with the Jinja2 template or
the data being passed to it.
"""
class PDFConversionError(ReportingError):
"""Raised when HTML to PDF conversion fails.
This typically indicates a problem with WeasyPrint or the generated
HTML/CSS being incompatible with PDF rendering.
"""
class ChartGenerationError(ReportingError):
"""Raised when chart generation fails.
This typically indicates a problem with matplotlib or the measurement
data being charted.
"""

View File

@@ -0,0 +1,199 @@
"""Report generator orchestrating the full report generation pipeline.
This module provides the main ReportGenerator class that coordinates
data gathering, chart generation, HTML rendering, and PDF conversion.
"""
from datetime import datetime
from pathlib import Path
from typing import Protocol
from uuid import UUID
from py_dvt_ate.data.repository import ITestRepository
from py_dvt_ate.reporting.charts import ChartGenerator
from py_dvt_ate.reporting.exceptions import ReportGenerationError
from py_dvt_ate.reporting.models import ReportConfig, ReportData
from py_dvt_ate.reporting.renderers import HTMLRenderer, PDFRenderer
class IReportGenerator(Protocol):
"""Protocol for report generators."""
def generate(self, run_id: UUID, output_path: Path | None = None) -> Path:
"""Generate a PDF report for a test run.
Args:
run_id: UUID of the test run.
output_path: Optional output path. If None, uses default location.
Returns:
Path to the generated PDF file.
"""
...
def generate_bytes(self, run_id: UUID) -> bytes:
"""Generate a PDF report and return as bytes.
Args:
run_id: UUID of the test run.
Returns:
PDF document as bytes.
"""
...
class ReportGenerator:
"""Generates PDF reports from test run data.
This class orchestrates the full report generation pipeline:
1. Fetch test run data from repository
2. Generate charts from measurements
3. Render HTML from templates
4. Convert HTML to PDF
Example:
>>> from py_dvt_ate.data.repository import SQLiteRepository
>>> from py_dvt_ate.reporting import ReportGenerator, ReportConfig
>>>
>>> repo = SQLiteRepository("./data/py_dvt_ate.db")
>>> config = ReportConfig(company_name="My Company")
>>> generator = ReportGenerator(repo, config)
>>> pdf_path = generator.generate(run_id)
"""
def __init__(
self,
repository: ITestRepository,
config: ReportConfig | None = None,
reports_dir: Path | None = None,
) -> None:
"""Initialise the report generator.
Args:
repository: Test data repository for fetching run data.
config: Report configuration. Uses defaults if not provided.
reports_dir: Directory for generated reports. Defaults to ./data/reports.
"""
self.repository = repository
self.config = config or ReportConfig()
self.reports_dir = reports_dir or Path("./data/reports")
self._html_renderer = HTMLRenderer()
self._pdf_renderer = PDFRenderer()
self._chart_generator = ChartGenerator(dpi=self.config.chart_dpi)
def _gather_data(self, run_id: UUID) -> ReportData:
"""Gather all data needed for the report.
Args:
run_id: UUID of the test run.
Returns:
ReportData containing run, results, measurements, and charts.
Raises:
ReportGenerationError: If data gathering fails.
"""
try:
run = self.repository.get_run(run_id)
results = self.repository.get_results(run_id)
measurements = self.repository.get_measurements_dataframe(run_id)
# Generate charts if enabled
charts: dict[str, str] = {}
if self.config.include_charts:
charts = self._chart_generator.generate_all(run, results, measurements)
return ReportData(
run=run,
results=results,
measurements=measurements,
charts=charts,
config=self.config,
)
except ValueError as e:
msg = f"Failed to gather data for run {run_id}: {e}"
raise ReportGenerationError(msg) from e
def _generate_filename(self, run_id: UUID, test_name: str) -> str:
"""Generate a filename for the report.
Args:
run_id: UUID of the test run.
test_name: Name of the test.
Returns:
Filename string with timestamp.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = test_name.replace(" ", "_").replace("/", "_")
return f"{safe_name}_{str(run_id)[:8]}_{timestamp}.pdf"
def generate(self, run_id: UUID, output_path: Path | None = None) -> Path:
"""Generate a PDF report for a test run.
Args:
run_id: UUID of the test run.
output_path: Optional output path. If None, uses default location.
Returns:
Path to the generated PDF file.
Raises:
ReportGenerationError: If report generation fails.
"""
try:
# Gather data
data = self._gather_data(run_id)
# Determine output path
if output_path is None:
self.reports_dir.mkdir(parents=True, exist_ok=True)
filename = self._generate_filename(run_id, data.run.test_name)
output_path = self.reports_dir / filename
# Render HTML
html = self._html_renderer.render(data)
# Convert to PDF
self._pdf_renderer.render_to_file(html, output_path)
return output_path
except ReportGenerationError:
raise
except Exception as e:
msg = f"Failed to generate report for run {run_id}: {e}"
raise ReportGenerationError(msg) from e
def generate_bytes(self, run_id: UUID) -> bytes:
"""Generate a PDF report and return as bytes.
Useful for streaming downloads without writing to disk.
Args:
run_id: UUID of the test run.
Returns:
PDF document as bytes.
Raises:
ReportGenerationError: If report generation fails.
"""
try:
# Gather data
data = self._gather_data(run_id)
# Render HTML
html = self._html_renderer.render(data)
# Convert to PDF bytes
return self._pdf_renderer.render_to_bytes(html)
except ReportGenerationError:
raise
except Exception as e:
msg = f"Failed to generate report bytes for run {run_id}: {e}"
raise ReportGenerationError(msg) from e

View File

@@ -0,0 +1,70 @@
"""Data models for report generation.
This module defines dataclasses for report configuration and data structures
used throughout the reporting pipeline.
"""
from dataclasses import dataclass, field
from pathlib import Path
import pandas as pd
from py_dvt_ate.data.models import TestResult, TestRun
@dataclass
class ReportConfig:
"""Configuration for report generation.
Attributes:
company_name: Company name to display in report header.
logo_path: Path to company logo image file (optional).
include_charts: Whether to include charts in the report.
chart_dpi: DPI for chart images (higher = better quality but larger file).
"""
company_name: str = "py_dvt_ate"
logo_path: Path | None = None
include_charts: bool = True
chart_dpi: int = 150
@dataclass
class ReportData:
"""Data container for report generation.
Contains all data needed to generate a test report including
test run metadata, results, measurements, and generated charts.
Attributes:
run: Test run metadata.
results: List of test results with pass/fail status.
measurements: DataFrame of time-series measurements (optional).
charts: Dictionary mapping chart names to base64-encoded PNG images.
config: Report configuration settings.
"""
run: TestRun
results: list[TestResult]
measurements: pd.DataFrame | None = None
charts: dict[str, str] = field(default_factory=dict)
config: ReportConfig = field(default_factory=ReportConfig)
@property
def passed_count(self) -> int:
"""Count of results that passed."""
return sum(1 for r in self.results if r.passed is True)
@property
def failed_count(self) -> int:
"""Count of results that failed."""
return sum(1 for r in self.results if r.passed is False)
@property
def overall_status(self) -> str:
"""Overall test status: PASS, FAIL, or ERROR."""
if self.run.status.value == "error":
return "ERROR"
if self.failed_count > 0:
return "FAIL"
return "PASS"

View File

@@ -0,0 +1,6 @@
"""Report renderers for HTML and PDF output."""
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
from py_dvt_ate.reporting.renderers.pdf import PDFRenderer
__all__ = ["HTMLRenderer", "PDFRenderer"]

View File

@@ -0,0 +1,100 @@
"""HTML renderer using Jinja2 templates.
This module provides HTML rendering for test reports using Jinja2 templating.
Templates are loaded from the package's templates directory.
"""
import base64
import json
from datetime import datetime
from importlib import resources
from pathlib import Path
from jinja2 import Environment, PackageLoader, select_autoescape
from py_dvt_ate import __version__
from py_dvt_ate.reporting.exceptions import TemplateRenderError
from py_dvt_ate.reporting.models import ReportData
class HTMLRenderer:
"""Renders HTML reports from ReportData using Jinja2 templates.
The renderer loads templates from the py_dvt_ate.reporting.templates package
and provides methods for rendering complete HTML reports.
"""
def __init__(self) -> None:
"""Initialise the HTML renderer with Jinja2 environment."""
self._env = Environment(
loader=PackageLoader("py_dvt_ate.reporting", "templates"),
autoescape=select_autoescape(["html", "xml"]),
)
self._css_content: str | None = None
def _load_css(self) -> str:
"""Load CSS content from the templates directory."""
if self._css_content is None:
templates_pkg = resources.files("py_dvt_ate.reporting.templates")
css_file = templates_pkg.joinpath("styles.css")
self._css_content = css_file.read_text()
return self._css_content
def _load_logo(self, logo_path: Path | None) -> str | None:
"""Load and encode logo image as base64.
Args:
logo_path: Path to logo image file.
Returns:
Base64-encoded image data, or None if no logo or file not found.
"""
if logo_path is None or not logo_path.exists():
return None
try:
with logo_path.open("rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except OSError:
return None
def render(self, data: ReportData) -> str:
"""Render a test report to HTML.
Args:
data: Report data containing test run, results, and charts.
Returns:
Complete HTML document as a string.
Raises:
TemplateRenderError: If template rendering fails.
"""
try:
template = self._env.get_template("test_report.html")
# Format config JSON for display
config_formatted = ""
if data.run.config_json:
try:
config_dict = json.loads(data.run.config_json)
config_formatted = json.dumps(config_dict, indent=2)
except json.JSONDecodeError:
config_formatted = data.run.config_json
# Prepare template context
context = {
"data": data,
"css_content": self._load_css(),
"logo_base64": self._load_logo(data.config.logo_path),
"company_name": data.config.company_name,
"version": __version__,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"config_formatted": config_formatted,
}
return template.render(**context)
except Exception as e:
msg = f"Failed to render HTML template: {e}"
raise TemplateRenderError(msg) from e

View File

@@ -0,0 +1,84 @@
"""PDF renderer using WeasyPrint.
This module provides PDF rendering from HTML content using WeasyPrint.
"""
from pathlib import Path
from py_dvt_ate.reporting.exceptions import PDFConversionError
class PDFRenderer:
"""Renders PDF documents from HTML content using WeasyPrint.
WeasyPrint converts HTML/CSS to PDF with support for page layout,
headers/footers, and professional typography.
"""
def __init__(self) -> None:
"""Initialise the PDF renderer."""
self._weasyprint: type | None = None
def _get_weasyprint(self) -> type:
"""Lazy-load WeasyPrint to avoid import errors when not installed.
Returns:
The WeasyPrint HTML class.
Raises:
PDFConversionError: If WeasyPrint is not installed.
"""
if self._weasyprint is None:
try:
from weasyprint import HTML
self._weasyprint = HTML
except ImportError as e:
msg = (
"WeasyPrint is required for PDF generation. "
"Install it with: pip install py_dvt_ate[reports]"
)
raise PDFConversionError(msg) from e
return self._weasyprint
def render_to_file(self, html: str, path: Path) -> None:
"""Render HTML content to a PDF file.
Args:
html: HTML content to convert.
path: Output path for the PDF file.
Raises:
PDFConversionError: If PDF conversion fails.
"""
try:
HTML = self._get_weasyprint()
path.parent.mkdir(parents=True, exist_ok=True)
HTML(string=html).write_pdf(path)
except PDFConversionError:
raise
except Exception as e:
msg = f"Failed to convert HTML to PDF: {e}"
raise PDFConversionError(msg) from e
def render_to_bytes(self, html: str) -> bytes:
"""Render HTML content to PDF bytes.
Args:
html: HTML content to convert.
Returns:
PDF document as bytes.
Raises:
PDFConversionError: If PDF conversion fails.
"""
try:
HTML = self._get_weasyprint()
result: bytes = HTML(string=html).write_pdf()
return result
except PDFConversionError:
raise
except Exception as e:
msg = f"Failed to convert HTML to PDF: {e}"
raise PDFConversionError(msg) from e

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Test Report{% endblock %}</title>
<style>
{{ css_content }}
</style>
</head>
<body>
<header class="report-header">
<div class="logo-block">
{% if logo_base64 %}
<img src="data:image/png;base64,{{ logo_base64 }}" alt="Company Logo" class="logo">
{% endif %}
</div>
<div class="title-block">
<h1>{% block header_title %}Test Report{% endblock %}</h1>
<div class="subtitle">{{ company_name }}</div>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="report-footer">
<p>Generated by py_dvt_ate v{{ version }} on {{ generated_at }}</p>
<p>{{ company_name }}</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,319 @@
/* Professional report stylesheet for py_dvt_ate
* Optimised for A4 PDF output via WeasyPrint
*/
/* Page setup for A4 */
@page {
size: A4;
margin: 20mm 15mm 25mm 15mm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #666;
}
@bottom-right {
content: "py_dvt_ate Report";
font-size: 9pt;
color: #666;
}
}
/* Base styles */
* {
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #333;
margin: 0;
padding: 0;
}
/* Headers */
h1 {
font-size: 20pt;
color: #1a1a1a;
margin: 0 0 10mm 0;
padding-bottom: 3mm;
border-bottom: 2px solid #2563eb;
}
h2 {
font-size: 14pt;
color: #1a1a1a;
margin: 8mm 0 4mm 0;
padding-bottom: 2mm;
border-bottom: 1px solid #e5e7eb;
}
h3 {
font-size: 12pt;
color: #374151;
margin: 6mm 0 3mm 0;
}
/* Report header section */
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8mm;
padding-bottom: 5mm;
border-bottom: 3px solid #2563eb;
}
.report-header .logo {
max-height: 20mm;
max-width: 50mm;
}
.report-header .title-block {
text-align: right;
}
.report-header .title-block h1 {
border: none;
margin: 0;
padding: 0;
}
.report-header .title-block .subtitle {
font-size: 11pt;
color: #6b7280;
margin-top: 2mm;
}
/* Metadata section */
.metadata {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 3mm;
margin-bottom: 6mm;
padding: 4mm;
background-color: #f9fafb;
border-radius: 2mm;
}
.metadata-item {
display: flex;
}
.metadata-item .label {
font-weight: 600;
color: #4b5563;
min-width: 35mm;
}
.metadata-item .value {
color: #1f2937;
}
/* Summary cards */
.summary-cards {
display: flex;
gap: 4mm;
margin: 6mm 0;
}
.summary-card {
flex: 1;
padding: 5mm;
border-radius: 2mm;
text-align: center;
}
.summary-card.pass {
background-color: #dcfce7;
border: 1px solid #86efac;
}
.summary-card.fail {
background-color: #fee2e2;
border: 1px solid #fca5a5;
}
.summary-card.info {
background-color: #dbeafe;
border: 1px solid #93c5fd;
}
.summary-card .count {
font-size: 24pt;
font-weight: 700;
line-height: 1.2;
}
.summary-card.pass .count {
color: #16a34a;
}
.summary-card.fail .count {
color: #dc2626;
}
.summary-card.info .count {
color: #2563eb;
}
.summary-card .label {
font-size: 9pt;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 1mm 3mm;
border-radius: 1mm;
font-size: 9pt;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.pass {
background-color: #dcfce7;
color: #16a34a;
}
.status-badge.fail {
background-color: #fee2e2;
color: #dc2626;
}
.status-badge.error {
background-color: #fef3c7;
color: #d97706;
}
.status-badge.pending {
background-color: #f3f4f6;
color: #6b7280;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 4mm 0;
font-size: 9pt;
}
th, td {
padding: 2.5mm 3mm;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
th {
background-color: #f9fafb;
font-weight: 600;
color: #374151;
border-bottom: 2px solid #d1d5db;
}
tr:hover {
background-color: #f9fafb;
}
/* Numeric columns right-aligned */
td.numeric {
text-align: right;
font-family: 'Courier New', Courier, monospace;
}
/* Results table specific */
.results-table .status-cell {
text-align: center;
width: 15mm;
}
.results-table .value-cell {
font-family: 'Courier New', Courier, monospace;
text-align: right;
}
.results-table .limit-cell {
font-family: 'Courier New', Courier, monospace;
text-align: right;
color: #6b7280;
font-size: 8pt;
}
/* Charts section */
.chart-container {
margin: 6mm 0;
text-align: center;
page-break-inside: avoid;
}
.chart-container img {
max-width: 100%;
height: auto;
border: 1px solid #e5e7eb;
border-radius: 2mm;
}
.chart-container .caption {
font-size: 9pt;
color: #6b7280;
margin-top: 2mm;
font-style: italic;
}
/* Configuration section */
.config-section {
background-color: #f9fafb;
padding: 4mm;
border-radius: 2mm;
margin: 4mm 0;
}
.config-section pre {
font-family: 'Courier New', Courier, monospace;
font-size: 8pt;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Page break utilities */
.page-break-before {
page-break-before: always;
}
.page-break-after {
page-break-after: always;
}
.avoid-break {
page-break-inside: avoid;
}
/* Footer */
.report-footer {
margin-top: 10mm;
padding-top: 4mm;
border-top: 1px solid #e5e7eb;
font-size: 8pt;
color: #9ca3af;
text-align: center;
}
/* Print optimisations */
@media print {
body {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.no-print {
display: none;
}
}

View File

@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}{{ data.run.test_name }} - Test Report{% endblock %}
{% block header_title %}{{ data.run.test_name }}{% endblock %}
{% block content %}
<section class="test-overview">
<h2>Test Overview</h2>
<div class="metadata">
<div class="metadata-item">
<span class="label">Run ID:</span>
<span class="value">{{ data.run.id }}</span>
</div>
<div class="metadata-item">
<span class="label">Test Name:</span>
<span class="value">{{ data.run.test_name }}</span>
</div>
<div class="metadata-item">
<span class="label">Started:</span>
<span class="value">{{ data.run.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</div>
<div class="metadata-item">
<span class="label">Completed:</span>
<span class="value">{% if data.run.completed_at %}{{ data.run.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}N/A{% endif %}</span>
</div>
<div class="metadata-item">
<span class="label">Operator:</span>
<span class="value">{{ data.run.operator or 'N/A' }}</span>
</div>
<div class="metadata-item">
<span class="label">Status:</span>
<span class="value">
<span class="status-badge {{ data.overall_status|lower }}">{{ data.overall_status }}</span>
</span>
</div>
{% if data.run.description %}
<div class="metadata-item" style="grid-column: span 2;">
<span class="label">Description:</span>
<span class="value">{{ data.run.description }}</span>
</div>
{% endif %}
</div>
</section>
<section class="results-summary">
<h2>Results Summary</h2>
<div class="summary-cards">
<div class="summary-card pass">
<div class="count">{{ data.passed_count }}</div>
<div class="label">Passed</div>
</div>
<div class="summary-card fail">
<div class="count">{{ data.failed_count }}</div>
<div class="label">Failed</div>
</div>
<div class="summary-card info">
<div class="count">{{ data.results|length }}</div>
<div class="label">Total Results</div>
</div>
</div>
</section>
<section class="results-table-section avoid-break">
<h2>Test Results</h2>
{% if data.results %}
<table class="results-table">
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Unit</th>
<th>Lower Limit</th>
<th>Upper Limit</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for result in data.results %}
<tr>
<td>{{ result.parameter }}</td>
<td class="value-cell">{{ "%.6f"|format(result.value) }}</td>
<td>{{ result.unit }}</td>
<td class="limit-cell">{% if result.lower_limit is not none %}{{ "%.6f"|format(result.lower_limit) }}{% else %}—{% endif %}</td>
<td class="limit-cell">{% if result.upper_limit is not none %}{{ "%.6f"|format(result.upper_limit) }}{% else %}—{% endif %}</td>
<td class="status-cell">
{% if result.passed is true %}
<span class="status-badge pass">PASS</span>
{% elif result.passed is false %}
<span class="status-badge fail">FAIL</span>
{% else %}
<span class="status-badge pending">N/A</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No results recorded for this test run.</p>
{% endif %}
</section>
{% if data.charts %}
<section class="charts-section page-break-before">
<h2>Charts</h2>
{% for chart_name, chart_base64 in data.charts.items() %}
<div class="chart-container avoid-break">
<h3>{{ chart_name }}</h3>
<img src="data:image/png;base64,{{ chart_base64 }}" alt="{{ chart_name }}">
</div>
{% endfor %}
</section>
{% endif %}
{% if data.run.config_json %}
<section class="configuration-section avoid-break">
<h2>Test Configuration</h2>
<div class="config-section">
<pre>{{ config_formatted }}</pre>
</div>
</section>
{% endif %}
{% endblock %}

View File

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

View File

@@ -0,0 +1,11 @@
"""Physics simulation package.
Provides virtual instruments backed by a coupled thermal-electrical
physics engine. Used for development and testing without real hardware.
Note: InstrumentServer has moved to py_dvt_ate.instruments.transport
"""
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
__all__ = ["ServerConfig", "SimulationServer"]

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,66 @@
"""Base interface for Device Under Test (DUT) models.
Defines the interface that all DUT models must implement to integrate
with the physics engine.
"""
from abc import ABC, abstractmethod
class DUTModel(ABC):
"""Abstract base class 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.
Implementations must inherit from this class and implement all abstract
methods.
"""
@abstractmethod
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.
"""
pass
@abstractmethod
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.
"""
pass
@abstractmethod
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.
"""
pass

View File

@@ -0,0 +1,221 @@
"""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
from py_dvt_ate.simulation.physics.models.base import DUTModel
@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(DUTModel):
"""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 interface.
"""
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 float(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,266 @@
"""Simulation server entry point.
This module provides the main entry point for running the simulation server
with all virtual instruments wired to a shared physics engine.
"""
from __future__ import annotations
import asyncio
import logging
import signal
from dataclasses import dataclass
from py_dvt_ate.instruments.transport import InstrumentServer
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
from py_dvt_ate.simulation.virtual.chamber import ThermalChamberSim
from py_dvt_ate.simulation.virtual.multimeter import MultimeterSim
from py_dvt_ate.simulation.virtual.power_supply import PowerSupplySim
logger = logging.getLogger(__name__)
@dataclass
class ServerConfig:
"""Configuration for the simulation server.
Attributes:
host: Host address to bind to.
chamber_port: Port for thermal chamber instrument.
psu_port: Port for power supply instrument.
dmm_port: Port for multimeter instrument.
physics_rate_hz: Physics engine update rate in Hz.
"""
host: str = "127.0.0.1"
chamber_port: int = 5000
psu_port: int = 5001
dmm_port: int = 5002
physics_rate_hz: float = 100.0
class SimulationServer:
"""Complete simulation server with physics engine and instruments.
Creates a physics engine and wires it to all virtual instruments,
then exposes them over TCP for client access.
"""
def __init__(self, config: ServerConfig | None = None) -> None:
"""Initialise the simulation server.
Args:
config: Server configuration. Uses defaults if not provided.
"""
self._config = config or ServerConfig()
self._physics_engine: PhysicsEngine | None = None
self._instrument_server: InstrumentServer | None = None
self._physics_task: asyncio.Task[None] | None = None
self._running = False
self._paused = False # Pause physics simulation
self._time_scale = 1.0 # Simulation time multiplier
@property
def is_running(self) -> bool:
"""Check if server is currently running."""
return self._running
@property
def paused(self) -> bool:
"""Check if physics simulation is paused."""
return self._paused
@paused.setter
def paused(self, value: bool) -> None:
"""Pause or resume the physics simulation."""
self._paused = value
@property
def physics_engine(self) -> PhysicsEngine | None:
"""Get the physics engine instance."""
return self._physics_engine
@property
def time_scale(self) -> float:
"""Get the current time scale multiplier."""
return self._time_scale
@time_scale.setter
def time_scale(self, value: float) -> None:
"""Set the time scale multiplier (e.g., 10.0 = 10x faster)."""
self._time_scale = max(0.1, min(value, 1000.0))
def _setup(self) -> None:
"""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:
if not self._paused:
# Step physics multiple times based on time scale
steps_per_tick = max(1, int(self._time_scale))
for _ in range(steps_per_tick):
self._physics_engine.step()
# Sleep for the physics timestep (wall clock time)
await asyncio.sleep(dt)
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,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,181 @@
"""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)
TEMP:RAMP <value> - Set temperature ramp rate in degrees C/min
TEMP:RAMP? - Query current ramp rate
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
self._ramp_rate = 10.0 # Default ramp rate in degrees C/min
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)
self.register_command("TEMP:RAMP", self._handle_temp_ramp)
def reset(self) -> None:
"""Reset chamber to default state."""
self._setpoint = 25.0
self._ramp_rate = 10.0
if self._physics_engine is not None:
self._physics_engine.set_chamber_setpoint(self._setpoint)
def _handle_temp_setpoint(self, command: SCPICommand) -> str:
"""Handle TEMP:SETPOINT command/query.
Args:
command: Parsed SCPI command.
Returns:
Setpoint value for query, empty string for set command.
Raises:
ValueError: If setpoint argument is invalid.
"""
if command.is_query:
return f"{self._setpoint:.2f}"
# Set command requires one argument
if not command.arguments:
raise ValueError("TEMP:SETPOINT requires a value")
try:
setpoint = float(command.arguments[0])
except ValueError as err:
raise ValueError(f"Invalid temperature value: {command.arguments[0]}") from err
self._setpoint = setpoint
if self._physics_engine is not None:
self._physics_engine.set_chamber_setpoint(setpoint)
return ""
def _handle_temp_actual(self, command: SCPICommand) -> str:
"""Handle TEMP:ACTUAL? query.
Args:
command: Parsed SCPI command.
Returns:
Actual chamber temperature.
Raises:
ValueError: If used as command (not query).
"""
if not command.is_query:
raise ValueError("TEMP:ACTUAL is query only")
if self._physics_engine is None:
# Return setpoint if no physics engine connected
return f"{self._setpoint:.2f}"
thermal_state = self._physics_engine.get_thermal_state()
return f"{thermal_state.chamber_temperature:.2f}"
def _handle_temp_stab(self, command: SCPICommand) -> str:
"""Handle TEMP:STAB? stability query.
Temperature is considered stable when the actual chamber temperature
is within STABILITY_THRESHOLD of the setpoint.
Args:
command: Parsed SCPI command.
Returns:
"1" if stable, "0" if settling.
Raises:
ValueError: If used as command (not query).
"""
if not command.is_query:
raise ValueError("TEMP:STAB is query only")
if self._physics_engine is None:
# Assume stable if no physics engine connected
return "1"
thermal_state = self._physics_engine.get_thermal_state()
error = abs(thermal_state.chamber_temperature - self._setpoint)
if error <= self.STABILITY_THRESHOLD:
return "1"
return "0"
def _handle_temp_ramp(self, command: SCPICommand) -> str:
"""Handle TEMP:RAMP command/query.
Args:
command: Parsed SCPI command.
Returns:
Ramp rate value for query, empty string for set command.
Raises:
ValueError: If ramp rate argument is invalid.
"""
if command.is_query:
return f"{self._ramp_rate:.2f}"
# Set command requires one argument
if not command.arguments:
raise ValueError("TEMP:RAMP requires a value")
try:
ramp_rate = float(command.arguments[0])
except ValueError as err:
raise ValueError(f"Invalid ramp rate value: {command.arguments[0]}") from err
if ramp_rate <= 0:
raise ValueError("Ramp rate must be positive")
self._ramp_rate = ramp_rate
# Note: Simulator doesn't currently model ramp rate dynamics
# The value is stored but not used in physics calculations
return ""

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 as err:
raise ValueError(f"Invalid voltage value: {command.arguments[0]}") from err
if voltage < 0:
raise ValueError("Voltage cannot be negative")
self._voltage_setpoint = voltage
if self._physics_engine is not None and self._output_enabled:
self._physics_engine.set_input_voltage(voltage)
return ""
def _handle_curr(self, command: SCPICommand) -> str:
"""Handle CURR command/query.
Args:
command: Parsed SCPI command.
Returns:
Current limit for query, empty string for set command.
Raises:
ValueError: If current argument is invalid.
"""
if command.is_query:
return f"{self._current_limit:.3f}"
if not command.arguments:
raise ValueError("CURR requires a value")
try:
current = float(command.arguments[0])
except ValueError as err:
raise ValueError(f"Invalid current value: {command.arguments[0]}") from err
if current < 0:
raise ValueError("Current limit cannot be negative")
self._current_limit = current
return ""
def _handle_outp(self, command: SCPICommand) -> str:
"""Handle OUTP command/query.
Args:
command: Parsed SCPI command.
Returns:
"1" or "0" for query, empty string for set command.
Raises:
ValueError: If output argument is invalid.
"""
if command.is_query:
return "1" if self._output_enabled else "0"
if not command.arguments:
raise ValueError("OUTP requires a value (ON, OFF, 1, or 0)")
arg = command.arguments[0].upper()
if arg in ("ON", "1"):
self._output_enabled = True
elif arg in ("OFF", "0"):
self._output_enabled = False
else:
raise ValueError(f"Invalid output state: {command.arguments[0]}")
if self._physics_engine is not None:
self._physics_engine.set_output_enabled(self._output_enabled)
if self._output_enabled:
self._physics_engine.set_input_voltage(self._voltage_setpoint)
else:
self._physics_engine.set_input_voltage(0.0)
return ""
def _handle_meas_volt(self, command: SCPICommand) -> str:
"""Handle MEAS:VOLT? query.
Args:
command: Parsed SCPI command.
Returns:
Measured output voltage.
Raises:
ValueError: If used as command (not query).
"""
if not command.is_query:
raise ValueError("MEAS:VOLT is query only")
if not self._output_enabled:
return "0.000"
if self._physics_engine is None:
return f"{self._voltage_setpoint:.3f}"
electrical_state = self._physics_engine.get_electrical_state()
return f"{electrical_state.input_voltage:.3f}"
def _handle_meas_curr(self, command: SCPICommand) -> str:
"""Handle MEAS:CURR? query.
Args:
command: Parsed SCPI command.
Returns:
Measured output current.
Raises:
ValueError: If used as command (not query).
"""
if not command.is_query:
raise ValueError("MEAS:CURR is query only")
if not self._output_enabled:
return "0.000"
if self._physics_engine is None:
return "0.000"
electrical_state = self._physics_engine.get_electrical_state()
# Total current is load current + quiescent current
total_current = electrical_state.load_current + electrical_state.quiescent_current
return f"{total_current:.3f}"

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,158 @@
"""Base class and utilities for DVT test implementations.
This module provides common functionality shared across all DVT tests,
including thermal settling helpers, measurement utilities, and statistical
calculations.
"""
import time
from abc import ABC
from collections.abc import Callable
from py_dvt_ate.framework.context import ITest, TestContext
class BaseDVTTest(ITest, ABC):
"""Abstract base class for DVT tests with common utilities.
Provides helper methods for thermal settling, measurement averaging,
and other common test patterns. All DVT tests should inherit from
this class rather than directly from ITest.
"""
def wait_for_temperature(
self,
context: TestContext,
setpoint: float,
timeout: float = 300.0,
poll_interval: float = 1.0,
) -> bool:
"""Wait for thermal chamber to stabilise at setpoint.
Sets the chamber temperature and waits until stable. Logs progress
to the test logger.
Args:
context: Test context with instruments and logger.
setpoint: Target temperature in degrees Celsius.
timeout: Maximum wait time in seconds. Default 300s (5 minutes).
poll_interval: Time between stability checks. Default 1s.
Returns:
True if temperature stabilised within timeout, False if timed out.
Raises:
ConnectionError: If instrument communication fails.
IOError: If instrument reports error.
"""
chamber = context.instruments.chamber
# Set the temperature
chamber.set_temperature(setpoint)
context.logger.log_event(
f"Set thermal chamber to {setpoint:.1f}°C, waiting for stability...",
level="INFO",
)
# Wait for stability
start_time = time.time()
elapsed = 0.0
while elapsed < timeout:
if chamber.is_stable():
actual = chamber.get_temperature()
context.logger.log_event(
f"Chamber stable at {actual:.2f}°C "
f"(target {setpoint:.1f}°C) after {elapsed:.1f}s",
level="INFO",
)
return True
time.sleep(poll_interval)
elapsed = time.time() - start_time
# Timeout
actual = chamber.get_temperature()
context.logger.log_event(
f"Timeout waiting for stability. Chamber at {actual:.2f}°C, "
f"target {setpoint:.1f}°C after {timeout:.1f}s",
level="WARNING",
)
return False
def measure_averaged(
self,
measurement_func: Callable[[], float],
num_samples: int = 5,
settle_time: float = 0.1,
) -> tuple[float, float]:
"""Take multiple measurements and return mean and standard deviation.
Useful for reducing noise in measurements by averaging multiple samples.
Args:
measurement_func: Function that returns a single measurement.
num_samples: Number of samples to average. Default 5.
settle_time: Delay between samples in seconds. Default 0.1s.
Returns:
Tuple of (mean, standard_deviation).
Raises:
ValueError: If num_samples < 1.
Exception: If measurement_func raises an exception.
"""
if num_samples < 1:
raise ValueError("num_samples must be at least 1")
samples: list[float] = []
for _ in range(num_samples):
if settle_time > 0 and len(samples) > 0:
time.sleep(settle_time)
samples.append(measurement_func())
mean = sum(samples) / len(samples)
if len(samples) == 1:
std_dev = 0.0
else:
variance = sum((x - mean) ** 2 for x in samples) / (len(samples) - 1)
std_dev = variance ** 0.5
return mean, std_dev
def thermal_settle(
self,
context: TestContext,
additional_settle_time: float = 5.0,
) -> None:
"""Wait for additional thermal settling after chamber reports stable.
After the chamber reports stable temperature, this adds additional
settling time to ensure the DUT junction temperature has also stabilised.
This is important for measurements sensitive to self-heating effects.
Args:
context: Test context with logger.
additional_settle_time: Extra settling time in seconds. Default 5s.
"""
if additional_settle_time > 0:
context.logger.log_event(
f"Additional thermal settling for {additional_settle_time:.1f}s...",
level="INFO",
)
time.sleep(additional_settle_time)
def delay(self, seconds: float, message: str | None = None) -> None:
"""Sleep for specified duration.
Simple utility for adding delays in test sequences.
Args:
seconds: Delay duration in seconds.
message: Optional message describing reason for delay.
"""
if message:
# Could log this if needed
pass
time.sleep(seconds)

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

@@ -0,0 +1,243 @@
"""Temperature Coefficient (TempCo) characterisation test.
This test characterises the output voltage temperature coefficient by
sweeping the chamber temperature and measuring output voltage at each point.
The TempCo is calculated from the linear regression slope and expressed
in parts per million per degree Celsius (ppm/C).
"""
from py_dvt_ate.data.models import TestStatus
from py_dvt_ate.framework.context import TestContext
from py_dvt_ate.tests.base import BaseDVTTest
class TempCoTest(BaseDVTTest):
"""Temperature coefficient characterisation test.
Measures how output voltage varies with temperature. This is a critical
parameter for voltage regulators, as it indicates stability across
the operating temperature range.
Test Procedure:
1. Configure DUT supply voltage and load current
2. Sweep chamber temperature from min to max
3. At each temperature point:
- Wait for thermal stability
- Measure output voltage (averaged)
- Log measurement with conditions
4. Calculate TempCo from linear regression
5. Evaluate against specification limits
Configuration:
temperatures: List of temperature points (C). Default: [-40, -20, 0, 25, 50, 85]
input_voltage: DUT input voltage (V). Default: 5.0
load_current: DUT load current (A). Default: 0.1
settle_time: Additional settling time at each temp (s). Default: 5.0
num_samples: Number of measurements to average per point. Default: 5
tempco_limit: Maximum allowed TempCo magnitude (ppm/C). Default: +/-50.0
"""
@property
def name(self) -> str:
"""Return test identifier."""
return "tempco"
@property
def description(self) -> str:
"""Return test description."""
return "Output voltage temperature coefficient"
def execute(self, context: TestContext) -> TestStatus:
"""Execute TempCo characterisation test.
Args:
context: Test context with instruments, logger, and configuration.
Returns:
PASSED if TempCo is within limits, FAILED otherwise.
ERROR if a critical failure occurs.
"""
try:
# Get configuration
config = context.config
temperatures = config.get("temperatures", [-40.0, -20.0, 0.0, 25.0, 50.0, 85.0])
input_voltage = config.get("input_voltage", 5.0)
load_current = config.get("load_current", 0.1)
settle_time = config.get("settle_time", 5.0)
num_samples = config.get("num_samples", 5)
tempco_limit = config.get("tempco_limit", 50.0)
context.logger.log_event(
f"Starting TempCo test: {len(temperatures)} temperature points, "
f"Vin={input_voltage}V, Iload={load_current}A",
level="INFO",
)
# Configure DUT power
context.logger.log_event(
f"Configuring PSU: Vin={input_voltage}V, Ilimit={load_current + 0.5}A",
level="INFO",
)
psu = context.instruments.psu
psu.set_voltage(1, input_voltage)
psu.set_current_limit(1, load_current + 0.5) # Add headroom
psu.enable_output(1, True)
# Storage for measurements
temp_points: list[float] = []
vout_points: list[float] = []
# Temperature sweep
for temp_setpoint in temperatures:
context.logger.log_event(
f"Temperature point: {temp_setpoint}C",
level="INFO",
)
# Wait for thermal stability
stable = self.wait_for_temperature(
context,
temp_setpoint,
timeout=300.0,
)
if not stable:
context.logger.log_event(
f"Warning: Temperature did not stabilise at {temp_setpoint}C",
level="WARNING",
)
# Additional settling for DUT junction temperature
self.thermal_settle(context, settle_time)
# Measure output voltage (averaged)
actual_temp = context.instruments.chamber.get_temperature()
def measure_vout() -> float:
return context.instruments.dmm.measure_dc_voltage()
vout_mean, vout_std = self.measure_averaged(
measure_vout,
num_samples=num_samples,
)
# Log individual measurement
context.logger.log_measurement(
parameter="v_out",
value=vout_mean,
unit="V",
conditions={
"temperature": actual_temp,
"input_voltage": input_voltage,
"load_current": load_current,
},
)
context.logger.log_event(
f"Measured Vout = {vout_mean:.6f}V +/- {vout_std * 1e6:.1f}uV "
f"at T={actual_temp:.2f}C",
level="INFO",
)
# Store for TempCo calculation
temp_points.append(actual_temp)
vout_points.append(vout_mean)
# Calculate TempCo from linear regression
tempco_ppm = self._calculate_tempco(temp_points, vout_points)
context.logger.log_event(
f"Calculated TempCo = {tempco_ppm:.2f} ppm/C",
level="INFO",
)
# Log result with limits
context.logger.log_result(
parameter="temp_co",
value=tempco_ppm,
unit="ppm/C",
lower_limit=-abs(tempco_limit),
upper_limit=abs(tempco_limit),
)
# Evaluate pass/fail
passed = abs(tempco_ppm) <= tempco_limit
if passed:
context.logger.log_event(
f"TempCo test PASSED: {tempco_ppm:.2f} ppm/C within +/-{tempco_limit} ppm/C",
level="INFO",
)
return TestStatus.PASSED
else:
context.logger.log_event(
f"TempCo test FAILED: {tempco_ppm:.2f} ppm/C exceeds +/-{tempco_limit} ppm/C",
level="ERROR",
)
return TestStatus.FAILED
except Exception as e:
context.logger.log_event(
f"TempCo test ERROR: {e!s}",
level="ERROR",
)
return TestStatus.ERROR
finally:
# Cleanup: disable PSU output
try:
context.instruments.psu.enable_output(1, False)
context.logger.log_event("PSU output disabled", level="INFO")
except Exception:
pass # Best effort cleanup
def _calculate_tempco(
self,
temperatures: list[float],
voltages: list[float],
) -> float:
"""Calculate temperature coefficient from measurements.
Uses linear regression to find the slope (dV/dT), then converts
to ppm/C relative to the nominal voltage (voltage at median temperature).
Args:
temperatures: Temperature measurements in C.
voltages: Output voltage measurements in V.
Returns:
Temperature coefficient in ppm/C.
Raises:
ValueError: If insufficient data points.
"""
if len(temperatures) < 2 or len(temperatures) != len(voltages):
raise ValueError("Need at least 2 matching temperature-voltage pairs")
n = len(temperatures)
# Linear regression: V = a + b*T
# We want slope b = dV/dT
mean_t = sum(temperatures) / n
mean_v = sum(voltages) / n
# Covariance and variance
cov = sum(
(t - mean_t) * (v - mean_v)
for t, v in zip(temperatures, voltages, strict=True)
)
var_t = sum((t - mean_t) ** 2 for t in temperatures)
if var_t == 0:
raise ValueError("Temperature variance is zero (all temps identical)")
slope = cov / var_t # dV/dT in V/C
# Find nominal voltage (voltage at median temperature)
sorted_pairs = sorted(zip(temperatures, voltages, strict=True))
mid_idx = len(sorted_pairs) // 2
v_nominal = sorted_pairs[mid_idx][1]
# Convert to ppm/C: (dV/dT) / V_nom * 10^6
tempco_ppm = (slope / v_nominal) * 1e6
return tempco_ppm

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,135 @@
"""Configuration for integration tests."""
import asyncio
import threading
import time
from collections.abc import Generator
import pytest
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
class ServerThread:
"""Helper class to run SimulationServer in a background thread.
This allows synchronous test code to run in the main thread while
the async server runs in its own thread with its own event loop.
"""
def __init__(self, config: ServerConfig):
"""Initialise the server thread.
Args:
config: Server configuration.
"""
self.config = config
self.server: SimulationServer | None = None
self.thread: threading.Thread | None = None
self.loop: asyncio.AbstractEventLoop | None = None
self._started = threading.Event()
self._error: Exception | None = None
def _run_server(self) -> None:
"""Run the server in the background thread."""
try:
# Create new event loop for this thread
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
# Create and start server
self.server = SimulationServer(self.config)
self.loop.run_until_complete(self.server.start())
# Signal that server is started
self._started.set()
# Run event loop until stopped
self.loop.run_forever()
except Exception as e:
self._error = e
self._started.set() # Unblock waiting thread even on error
finally:
# Cleanup
if self.server is not None and self.loop is not None:
try:
self.loop.run_until_complete(self.server.stop())
except Exception:
pass
if self.loop is not None:
self.loop.close()
def start(self, timeout: float = 5.0) -> None:
"""Start the server thread.
Args:
timeout: Maximum time to wait for server to start (seconds).
Raises:
RuntimeError: If server fails to start within timeout.
Exception: If server raises an exception during startup.
"""
self.thread = threading.Thread(target=self._run_server, daemon=True)
self.thread.start()
# Wait for server to start
if not self._started.wait(timeout=timeout):
raise RuntimeError("Server failed to start within timeout")
# Check if there was an error during startup
if self._error is not None:
raise self._error
# Give server a bit more time to fully initialize
time.sleep(0.1)
def stop(self, timeout: float = 5.0) -> None:
"""Stop the server thread.
Args:
timeout: Maximum time to wait for server to stop (seconds).
"""
if self.loop is not None and self.loop.is_running():
# Schedule stop in the server's event loop
self.loop.call_soon_threadsafe(self.loop.stop)
if self.thread is not None:
self.thread.join(timeout=timeout)
self.thread = None
@pytest.fixture
def simulation_server() -> Generator[ServerConfig, None, None]:
"""Provide a running simulation server for integration tests.
The server runs in a background thread with its own event loop,
allowing synchronous test code to run in the main thread.
Yields:
ServerConfig with connection details for the running server.
"""
# Use unique ports for each test to avoid conflicts
import random
base_port = random.randint(20000, 30000)
config = ServerConfig(
host="127.0.0.1",
chamber_port=base_port,
psu_port=base_port + 1,
dmm_port=base_port + 2,
physics_rate_hz=100.0,
)
server_thread = ServerThread(config)
server_thread.start()
# Speed up simulation for tests (100x faster)
if server_thread.server is not None:
server_thread.server.time_scale = 100.0
try:
yield config
finally:
server_thread.stop()

View File

@@ -0,0 +1,289 @@
"""End-to-end integration tests for py_dvt_ate.
This module contains comprehensive tests that exercise the entire system:
- Simulation server startup
- Instrument connectivity via HAL
- Test execution through the framework
- Data persistence
- Results retrieval
These tests verify that all components work together correctly in a
complete workflow from server start to results analysis.
"""
from pathlib import Path
from tempfile import TemporaryDirectory
from py_dvt_ate.data.models import TestStatus
from py_dvt_ate.data.repository import SQLiteRepository
from py_dvt_ate.framework.runner import TestRunner
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
from py_dvt_ate.simulation.server import ServerConfig
from py_dvt_ate.tests.thermal.tempco import TempCoTest
def test_e2e_tempco_characterization(simulation_server: ServerConfig) -> None:
"""End-to-end test: Run complete TempCo characterization workflow.
This test exercises the entire system:
1. Simulation server is running (from fixture)
2. Create instruments via HAL factory
3. Create test repository and runner
4. Execute TempCo test
5. Verify results are persisted
6. Verify measurements are stored
7. Retrieve and analyze results
This is the closest test to real-world usage, verifying that all
components integrate correctly.
"""
with TemporaryDirectory() as tmpdir:
# Step 1: Create instruments via HAL
instrument_config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments = InstrumentFactory.create(instrument_config)
# Connect to instruments
instruments.chamber.transport.connect()
instruments.psu.transport.connect()
instruments.dmm.transport.connect()
# Verify instrument connectivity
idn = instruments.chamber.get_temperature() # Should not raise
assert isinstance(idn, float)
# Step 2: Create repository and test runner
db_path = Path(tmpdir) / "test.db"
repository = SQLiteRepository(str(db_path))
runner = TestRunner(repository)
# Step 3: Execute TempCo test with minimal config for speed
test = TempCoTest()
config = {
"temperatures": [0.0, 25.0, 50.0], # Reduced for test speed
"input_voltage": 5.0,
"load_current": 0.1,
"settle_time": 0.5, # Reduced for test speed
"num_samples": 3, # Reduced for test speed
"tempco_limit": 100.0, # Relaxed for test
}
run_id = runner.run_test(
test=test,
instruments=instruments,
config=config,
operator="test_user",
description="E2E integration test",
)
# Step 4: Verify run was created
assert run_id is not None
run = repository.get_run(run_id)
assert run.test_name == "tempco"
assert run.status in [TestStatus.PASSED, TestStatus.FAILED] # Either is valid
assert run.completed_at is not None
assert run.operator == "test_user"
# Step 5: Verify results were stored
results = repository.get_results(run_id)
assert len(results) > 0
# Should have TempCo result
tempco_result = next((r for r in results if r.parameter == "temp_co"), None)
assert tempco_result is not None
assert tempco_result.unit == "ppm/C"
assert tempco_result.lower_limit is not None
assert tempco_result.upper_limit is not None
# Step 6: Verify measurements were stored
measurements_df = repository.get_measurements_dataframe(run_id)
assert measurements_df is not None
assert not measurements_df.empty
# Should have v_out measurements
v_out_measurements = measurements_df[measurements_df["parameter"] == "v_out"]
assert len(v_out_measurements) >= 3 # At least one per temperature point
# Step 7: Verify data integrity
# All measurements should have valid values
assert (measurements_df["value"] > 0).all()
# All measurements should have units
assert measurements_df["unit"].notna().all()
# Timestamps should be monotonically increasing
assert measurements_df["timestamp"].is_monotonic_increasing
# Cleanup: close repository before tempdir cleanup (Windows file locking)
repository.close()
def test_e2e_server_lifecycle(simulation_server: ServerConfig) -> None:
"""Test simulation server lifecycle management.
Verifies that:
- Server starts successfully
- Physics engine is running
- Multiple instruments can connect
- Server can be stopped cleanly
"""
# Server is already running from fixture (in background thread)
# We verify it works by connecting instruments
# Multiple instruments should be able to connect
config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments1 = InstrumentFactory.create(config)
instruments2 = InstrumentFactory.create(config)
# Connect instruments
instruments1.chamber.transport.connect()
instruments2.chamber.transport.connect()
# Both should work independently
temp1 = instruments1.chamber.get_temperature()
temp2 = instruments2.chamber.get_temperature()
assert isinstance(temp1, float)
assert isinstance(temp2, float)
# Both should read similar values (same simulation)
assert abs(temp1 - temp2) < 1.0 # Within 1 degree
def test_e2e_instrument_hal_abstraction(simulation_server: ServerConfig) -> None:
"""Test Hardware Abstraction Layer works correctly.
Verifies that:
- Instruments implement HAL interfaces
- Commands work through HAL
- State changes propagate through physics
"""
config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments = InstrumentFactory.create(config)
# Connect instruments
instruments.chamber.transport.connect()
instruments.psu.transport.connect()
instruments.dmm.transport.connect()
# Test thermal chamber HAL
instruments.chamber.set_temperature(30.0)
setpoint = instruments.chamber.get_setpoint()
assert setpoint == 30.0
# Test power supply HAL
instruments.psu.set_voltage(1, 5.0)
instruments.psu.set_current_limit(1, 0.5)
instruments.psu.enable_output(1, True)
voltage_setpoint = instruments.psu.get_voltage(1)
assert voltage_setpoint == 5.0
enabled = instruments.psu.is_output_enabled(1)
assert enabled is True
# Wait a moment for physics to update
import time
time.sleep(0.1)
# Measure voltage with DMM
measured_voltage = instruments.dmm.measure_dc_voltage()
assert isinstance(measured_voltage, float)
# Should be reading DUT output voltage (close to nominal)
assert 3.0 < measured_voltage < 3.6 # LDO output
def test_e2e_multiple_test_runs(simulation_server: ServerConfig) -> None:
"""Test running multiple tests sequentially.
Verifies that:
- Multiple tests can be run in sequence
- Each test gets its own run ID
- All results are stored correctly
- Repository handles multiple runs
"""
with TemporaryDirectory() as tmpdir:
config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments = InstrumentFactory.create(config)
# Connect instruments
instruments.chamber.transport.connect()
instruments.psu.transport.connect()
instruments.dmm.transport.connect()
db_path = Path(tmpdir) / "test.db"
repository = SQLiteRepository(str(db_path))
runner = TestRunner(repository)
# Run same test twice with different configs
test = TempCoTest()
config1 = {
"temperatures": [0.0, 25.0], # Need at least 2 points for TempCo
"input_voltage": 5.0,
"load_current": 0.1,
"settle_time": 0.5,
"num_samples": 3,
"tempco_limit": 100.0,
}
config2 = {
"temperatures": [25.0, 50.0], # Need at least 2 points for TempCo
"input_voltage": 3.3,
"load_current": 0.05,
"settle_time": 0.5,
"num_samples": 3,
"tempco_limit": 100.0,
}
run_id1 = runner.run_test(test, instruments, config1, operator="test_user_1")
run_id2 = runner.run_test(test, instruments, config2, operator="test_user_2")
# Both runs should complete
assert run_id1 != run_id2
run1 = repository.get_run(run_id1)
run2 = repository.get_run(run_id2)
assert run1.operator == "test_user_1"
assert run2.operator == "test_user_2"
# Both should have results
results1 = repository.get_results(run_id1)
results2 = repository.get_results(run_id2)
assert len(results1) > 0
assert len(results2) > 0
# Verify get_all_runs works
all_runs = repository.get_all_runs()
assert len(all_runs) >= 2
assert any(r.id == str(run_id1) for r in all_runs)
assert any(r.id == str(run_id2) for r in all_runs)
# Cleanup: close repository before tempdir cleanup (Windows file locking)
repository.close()

View File

@@ -0,0 +1,291 @@
"""Integration tests for report generation.
Tests the full report generation pipeline from test run to PDF output.
"""
import tempfile
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from py_dvt_ate.data.models import Measurement, TestStatus
from py_dvt_ate.data.repository import SQLiteRepository
class TestReportGenerationIntegration:
"""Integration tests for the report generation pipeline."""
@pytest.fixture
def temp_dir(self) -> Path:
"""Create a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
@pytest.fixture
def repository(self, temp_dir: Path) -> SQLiteRepository:
"""Create a test repository with sample data."""
db_path = temp_dir / "test.db"
measurements_dir = temp_dir / "measurements"
repo = SQLiteRepository(db_path, measurements_dir)
# Create a test run
test_config = {
"temperatures": [-40.0, 25.0, 85.0],
"input_voltage": 5.0,
"load_current": 0.1,
}
run_id = repo.create_run(
test_name="tempco",
config=test_config,
operator="test_operator",
description="Integration test run",
)
# Add some results
repo.save_result(
run_id=run_id,
parameter="tempco",
value=48.5,
unit="ppm/C",
lower_limit=None,
upper_limit=100.0,
)
repo.save_result(
run_id=run_id,
parameter="output_voltage_m40c",
value=3.2965,
unit="V",
lower_limit=3.2,
upper_limit=3.4,
)
repo.save_result(
run_id=run_id,
parameter="output_voltage_25c",
value=3.3000,
unit="V",
lower_limit=3.2,
upper_limit=3.4,
)
repo.save_result(
run_id=run_id,
parameter="output_voltage_85c",
value=3.2901,
unit="V",
lower_limit=3.2,
upper_limit=3.4,
)
# Add measurements
measurements = []
temperatures = [-40.0, 0.0, 25.0, 50.0, 85.0]
voltages = [3.2965, 3.2985, 3.3000, 3.2960, 3.2901]
for i, (temp, voltage) in enumerate(zip(temperatures, voltages, strict=False)):
measurements.append(
Measurement(
timestamp=float(i * 60),
parameter="output_voltage",
value=voltage,
unit="V",
temperature=temp,
input_voltage=5.0,
load_current=0.1,
)
)
repo.save_measurements(run_id, measurements)
# Complete the run
repo.complete_run(run_id, TestStatus.PASSED)
return repo
@pytest.fixture
def run_id(self, repository: SQLiteRepository) -> UUID:
"""Get the test run ID from the repository."""
runs = repository.get_all_runs()
return UUID(runs[0].id)
def test_full_report_generation(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test complete report generation pipeline."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
# Create report config
config = ReportConfig(
company_name="Test Company Ltd",
include_charts=True,
chart_dpi=100, # Lower for faster tests
)
# Create generator
reports_dir = temp_dir / "reports"
generator = ReportGenerator(
repository=repository,
config=config,
reports_dir=reports_dir,
)
# Generate report
pdf_path = generator.generate(run_id)
# Verify PDF was created
assert pdf_path.exists()
assert pdf_path.suffix == ".pdf"
assert pdf_path.stat().st_size > 1000 # Should be non-trivial size
# Verify it's in the reports directory
assert pdf_path.parent == reports_dir
def test_report_generation_custom_path(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test report generation with custom output path."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
config = ReportConfig(include_charts=False) # No charts for faster test
generator = ReportGenerator(
repository=repository,
config=config,
)
# Generate to custom path
custom_path = temp_dir / "custom_report.pdf"
pdf_path = generator.generate(run_id, output_path=custom_path)
assert pdf_path == custom_path
assert pdf_path.exists()
def test_report_generation_as_bytes(
self, repository: SQLiteRepository, run_id: UUID
) -> None:
"""Test generating report as bytes."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
config = ReportConfig(include_charts=False)
generator = ReportGenerator(
repository=repository,
config=config,
)
# Generate as bytes
pdf_bytes = generator.generate_bytes(run_id)
# Verify it's a valid PDF
assert isinstance(pdf_bytes, bytes)
assert pdf_bytes.startswith(b"%PDF") # PDF magic bytes
assert len(pdf_bytes) > 1000
def test_report_includes_all_data(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test that generated report includes all expected data."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
config = ReportConfig(
company_name="Test Company",
include_charts=False,
)
generator = ReportGenerator(
repository=repository,
config=config,
)
# Get HTML (intermediate step) to check content
data = generator._gather_data(run_id)
html_renderer = HTMLRenderer()
html = html_renderer.render(data)
# Check for expected content
assert "tempco" in html
assert "Test Company" in html
assert "48.500000" in html # tempco value
assert "3.300000" in html # output voltage
assert "test_operator" in html
assert "Integration test run" in html
assert "PASS" in html
def test_report_with_failed_results(
self, temp_dir: Path
) -> None:
"""Test report generation with failed test results."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
# Create repository with a failed test
db_path = temp_dir / "failed_test.db"
repo = SQLiteRepository(db_path, temp_dir / "measurements")
run_id = repo.create_run(
test_name="failed_test",
config={},
operator="test",
)
# Add failing result
repo.save_result(
run_id=run_id,
parameter="test_param",
value=150.0, # Exceeds limit
unit="X",
lower_limit=0.0,
upper_limit=100.0,
)
repo.complete_run(run_id, TestStatus.FAILED)
# Generate report
config = ReportConfig(include_charts=False)
generator = ReportGenerator(repository=repo, config=config)
pdf_bytes = generator.generate_bytes(run_id)
# Should still generate
assert pdf_bytes.startswith(b"%PDF")
def test_report_generation_invalid_run_id(
self, repository: SQLiteRepository, temp_dir: Path
) -> None:
"""Test that invalid run ID raises appropriate error."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerationError, ReportGenerator
config = ReportConfig()
generator = ReportGenerator(repository=repository, config=config)
invalid_id = uuid4()
with pytest.raises(ReportGenerationError):
generator.generate(invalid_id)
def test_report_charts_generation(
self, repository: SQLiteRepository, run_id: UUID, temp_dir: Path
) -> None:
"""Test that charts are generated when enabled."""
from py_dvt_ate.reporting import ReportConfig, ReportGenerator
config = ReportConfig(include_charts=True, chart_dpi=72)
generator = ReportGenerator(
repository=repository,
config=config,
reports_dir=temp_dir / "reports",
)
# Gather data and check charts
data = generator._gather_data(run_id)
# Should have at least one chart (results bar chart)
assert len(data.charts) >= 1
# Voltage vs temperature chart should be present (we have voltage measurements)
assert "Voltage vs Temperature" in data.charts or "Results Summary" in data.charts

View File

@@ -0,0 +1,275 @@
"""Integration tests for TCP server.
Tests the InstrumentServer and SimulationServer with actual TCP connections.
"""
from __future__ import annotations
import asyncio
import pytest
from py_dvt_ate.instruments.transport import InstrumentServer
from py_dvt_ate.simulation.physics.engine import PhysicsEngine
from py_dvt_ate.simulation.server import ServerConfig, SimulationServer
from py_dvt_ate.simulation.virtual.chamber import ThermalChamberSim
@pytest.mark.asyncio(loop_scope="function")
class TestInstrumentServer:
"""Tests for InstrumentServer TCP functionality."""
@pytest.fixture
def physics_engine(self) -> PhysicsEngine:
"""Create a physics engine for testing."""
return PhysicsEngine(update_rate_hz=100.0)
@pytest.fixture
def server(self, physics_engine: PhysicsEngine) -> InstrumentServer:
"""Create an instrument server with a thermal chamber."""
server = InstrumentServer(host="127.0.0.1")
chamber = ThermalChamberSim(physics_engine)
server.register_instrument(15000, chamber)
return server
async def test_server_start_stop(self, server: InstrumentServer) -> None:
"""Test server can start and stop."""
assert not server.is_running
await server.start()
assert server.is_running
await server.stop()
assert not server.is_running
async def test_client_connection(self, server: InstrumentServer) -> None:
"""Test client can connect and send command."""
await server.start()
try:
reader, writer = await asyncio.open_connection("127.0.0.1", 15000)
# Send *IDN? query
writer.write(b"*IDN?\n")
await writer.drain()
# Read response
response = await asyncio.wait_for(reader.readline(), timeout=2.0)
assert b"PyDVTATE" in response
assert b"TC-SIM-001" in response
writer.close()
await writer.wait_closed()
finally:
await server.stop()
async def test_multiple_commands(self, server: InstrumentServer) -> None:
"""Test sending multiple commands in sequence."""
await server.start()
try:
reader, writer = await asyncio.open_connection("127.0.0.1", 15000)
# Set temperature setpoint
writer.write(b"TEMP:SETPOINT 85.0\n")
await writer.drain()
# Small delay to ensure server processes command before next one
await asyncio.sleep(0.01)
# 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()
await asyncio.sleep(0.01) # Allow server to process
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,250 @@
"""Integration tests for TempCo characterisation test.
Full end-to-end test of the TempCo test with simulated instruments.
"""
from __future__ import annotations
from pathlib import Path
from py_dvt_ate.data.models import TestStatus
from py_dvt_ate.data.repository import SQLiteRepository
from py_dvt_ate.framework.context import TestContext
from py_dvt_ate.framework.logger import TestLogger
from py_dvt_ate.instruments.factory import InstrumentConfig, InstrumentFactory
from py_dvt_ate.simulation.server import ServerConfig
from py_dvt_ate.tests.thermal.tempco import TempCoTest
class TestTempCoIntegration:
"""Integration tests for TempCo test with simulator."""
def test_tempco_runs_successfully(
self, tmp_path: Path, simulation_server: ServerConfig
) -> None:
"""Test TempCo test runs end-to-end with simulator."""
# Create instrument set connected to simulator
instrument_config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments = InstrumentFactory.create(instrument_config)
# Create test repository
db_path = tmp_path / "test.db"
repository = SQLiteRepository(db_path)
# Create test run
run_id = repository.create_run(
test_name="tempco",
config={
"temperatures": [23.0, 25.0, 27.0], # Close to start temp for fast settling
"input_voltage": 5.0,
"load_current": 0.1,
"settle_time": 0.2, # Short since temps close to start
"num_samples": 3, # Reduced for faster test
"tempco_limit": 100.0, # Relaxed for testing
},
description="Integration test of TempCo",
)
# Create test logger
logger = TestLogger(run_id, repository)
# Create test context
context = TestContext(
run_id=run_id,
instruments=instruments,
logger=logger,
config={
"temperatures": [23.0, 25.0, 27.0],
"input_voltage": 5.0,
"load_current": 0.1,
"settle_time": 0.2,
"num_samples": 3,
"tempco_limit": 100.0,
},
)
# Create test
test = TempCoTest()
assert test.name == "tempco"
assert test.description == "Output voltage temperature coefficient"
# Connect to instruments
instruments.chamber.connect() # type: ignore[attr-defined]
instruments.psu.connect() # type: ignore[attr-defined]
instruments.dmm.connect() # type: ignore[attr-defined]
try:
# Configure instruments
instruments.chamber.set_ramp_rate(10.0) # Fast ramp for testing
instruments.psu.enable_output(1, False) # Ensure off initially
# Run test
status = test.execute(context)
# Verify test completed
assert status in (TestStatus.PASSED, TestStatus.FAILED)
# Flush logger to ensure all data is written
logger.flush()
# Update run status
repository.complete_run(run_id, status)
# Verify results were logged
results = repository.get_results(run_id)
assert len(results) > 0
# Find TempCo result
tempco_result = next(r for r in results if r.parameter == "temp_co")
assert tempco_result is not None
assert tempco_result.unit == "ppm/C"
assert tempco_result.lower_limit == -100.0
assert tempco_result.upper_limit == 100.0
# Verify measurements were logged
df = repository.get_measurements_dataframe(run_id)
assert df is not None
assert len(df) >= 3 # At least 3 temperature points
# Verify v_out measurements exist
vout_measurements = df[df["parameter"] == "v_out"]
assert len(vout_measurements) >= 3
# Verify temperature conditions were logged
assert "temperature" in df.columns
temps_recorded = vout_measurements["temperature"].unique()
assert len(temps_recorded) >= 3
finally:
# Disconnect from instruments
instruments.chamber.disconnect() # type: ignore[attr-defined]
instruments.psu.disconnect() # type: ignore[attr-defined]
instruments.dmm.disconnect() # type: ignore[attr-defined]
def test_tempco_with_minimal_config(
self, tmp_path: Path, simulation_server: ServerConfig
) -> None:
"""Test TempCo uses default configuration when not specified."""
# Create instrument set
instrument_config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments = InstrumentFactory.create(instrument_config)
# Create repository
db_path = tmp_path / "test_minimal.db"
repository = SQLiteRepository(db_path)
run_id = repository.create_run(
test_name="tempco",
config={}, # Empty config - should use defaults
)
# Create logger and context with minimal config
logger = TestLogger(run_id, repository)
context = TestContext(
run_id=run_id,
instruments=instruments,
logger=logger,
config={
# Override temperatures for faster test
"temperatures": [24.0, 26.0],
"settle_time": 0.2,
"num_samples": 2,
},
)
# Execute test
test = TempCoTest()
# Connect to instruments
instruments.chamber.connect() # type: ignore[attr-defined]
instruments.psu.connect() # type: ignore[attr-defined]
instruments.dmm.connect() # type: ignore[attr-defined]
try:
# Run test
status = test.execute(context)
# Should complete without error
assert status in (TestStatus.PASSED, TestStatus.FAILED, TestStatus.ERROR)
logger.flush()
repository.complete_run(run_id, status)
# Verify some data was logged
results = repository.get_results(run_id)
assert len(results) >= 1
finally:
# Disconnect from instruments
instruments.chamber.disconnect() # type: ignore[attr-defined]
instruments.psu.disconnect() # type: ignore[attr-defined]
instruments.dmm.disconnect() # type: ignore[attr-defined]
def test_tempco_handles_errors_gracefully(
self, tmp_path: Path, simulation_server: ServerConfig
) -> None:
"""Test TempCo returns ERROR status when instruments fail."""
# Create instrument set
instrument_config = InstrumentConfig(
backend="simulator",
simulator_host=simulation_server.host,
chamber_port=simulation_server.chamber_port,
psu_port=simulation_server.psu_port,
dmm_port=simulation_server.dmm_port,
)
instruments = InstrumentFactory.create(instrument_config)
# Create repository
db_path = tmp_path / "test_error.db"
repository = SQLiteRepository(db_path)
run_id = repository.create_run(test_name="tempco", config={})
# Create logger and context
logger = TestLogger(run_id, repository)
context = TestContext(
run_id=run_id,
instruments=instruments,
logger=logger,
config={
"temperatures": [], # Invalid: empty temperature list
"settle_time": 0.1,
},
)
# Execute test
test = TempCoTest()
# Connect to instruments
instruments.chamber.connect() # type: ignore[attr-defined]
instruments.psu.connect() # type: ignore[attr-defined]
instruments.dmm.connect() # type: ignore[attr-defined]
try:
# Should handle gracefully (may return FAILED or ERROR)
# The test should not raise an unhandled exception
try:
status = test.execute(context)
# If it completes, it should indicate an error or failure
assert status in (TestStatus.ERROR, TestStatus.FAILED)
except Exception:
# Or it might raise, which we also consider handled
pass
logger.flush()
finally:
# Disconnect from instruments
instruments.chamber.disconnect() # type: ignore[attr-defined]
instruments.psu.disconnect() # type: ignore[attr-defined]
instruments.dmm.disconnect() # type: ignore[attr-defined]

View File

@@ -0,0 +1 @@
"""Unit tests for reporting module."""

View File

@@ -0,0 +1,220 @@
"""Unit tests for chart generator."""
import base64
from datetime import datetime
import pandas as pd
import pytest
from py_dvt_ate.data.models import TestResult, TestRun, TestStatus
from py_dvt_ate.reporting.charts.matplotlib_charts import ChartGenerator
from py_dvt_ate.reporting.exceptions import ChartGenerationError
class TestChartGenerator:
"""Tests for ChartGenerator class."""
@pytest.fixture
def generator(self) -> ChartGenerator:
"""Create a chart generator instance."""
return ChartGenerator(dpi=100) # Lower DPI for faster tests
@pytest.fixture
def sample_run(self) -> TestRun:
"""Create a sample test run."""
return TestRun(
id="12345678-1234-1234-1234-123456789abc",
test_name="tempco",
started_at=datetime(2024, 1, 15, 10, 30, 0),
status=TestStatus.PASSED,
config_json="{}",
)
@pytest.fixture
def sample_results(self) -> list[TestResult]:
"""Create sample test results."""
return [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="tempco",
value=45.0,
unit="ppm/C",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
),
TestResult(
id="result-2",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="output_voltage_25c",
value=3.3001,
unit="V",
measured_at=datetime(2024, 1, 15, 10, 33, 0),
),
]
@pytest.fixture
def voltage_measurements(self) -> pd.DataFrame:
"""Create sample voltage measurements."""
return pd.DataFrame(
{
"timestamp": [0.0, 100.0, 200.0, 300.0, 400.0],
"parameter": [
"output_voltage",
"output_voltage",
"output_voltage",
"output_voltage",
"output_voltage",
],
"value": [3.300, 3.298, 3.295, 3.290, 3.285],
"unit": ["V", "V", "V", "V", "V"],
"temperature": [-40.0, 0.0, 25.0, 50.0, 85.0],
}
)
def test_generator_initialisation(self) -> None:
"""Test chart generator initialisation."""
generator = ChartGenerator(dpi=200)
assert generator.dpi == 200
def test_generate_voltage_vs_temperature(
self, generator: ChartGenerator, voltage_measurements: pd.DataFrame
) -> None:
"""Test generating voltage vs temperature chart."""
chart_b64 = generator.generate_voltage_vs_temperature(voltage_measurements)
# Should be valid base64
assert isinstance(chart_b64, str)
assert len(chart_b64) > 100 # Should have meaningful content
# Should decode to PNG image
decoded = base64.b64decode(chart_b64)
assert decoded[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes
def test_generate_voltage_vs_temperature_no_data(
self, generator: ChartGenerator
) -> None:
"""Test that error is raised with no voltage data."""
empty_df = pd.DataFrame(
{
"timestamp": [0.0],
"parameter": ["other_param"],
"value": [1.0],
"unit": ["X"],
"temperature": [25.0],
}
)
with pytest.raises(ChartGenerationError):
generator.generate_voltage_vs_temperature(empty_df)
def test_generate_results_bar_chart(
self, generator: ChartGenerator, sample_results: list[TestResult]
) -> None:
"""Test generating results bar chart."""
chart_b64 = generator.generate_results_bar_chart(sample_results)
# Should be valid base64
assert isinstance(chart_b64, str)
assert len(chart_b64) > 100
# Should decode to PNG image
decoded = base64.b64decode(chart_b64)
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
def test_generate_results_bar_chart_empty(
self, generator: ChartGenerator
) -> None:
"""Test that error is raised with no results."""
with pytest.raises(ChartGenerationError):
generator.generate_results_bar_chart([])
def test_generate_all_with_measurements(
self,
generator: ChartGenerator,
sample_run: TestRun,
sample_results: list[TestResult],
voltage_measurements: pd.DataFrame,
) -> None:
"""Test generate_all produces expected charts."""
charts = generator.generate_all(sample_run, sample_results, voltage_measurements)
# Should have both chart types
assert "Voltage vs Temperature" in charts
assert "Results Summary" in charts
# All should be valid base64
for name, b64 in charts.items():
assert isinstance(b64, str)
decoded = base64.b64decode(b64)
assert decoded[:8] == b"\x89PNG\r\n\x1a\n", f"Chart {name} is not valid PNG"
def test_generate_all_no_measurements(
self,
generator: ChartGenerator,
sample_run: TestRun,
sample_results: list[TestResult],
) -> None:
"""Test generate_all with no measurements."""
charts = generator.generate_all(sample_run, sample_results, None)
# Should only have results chart
assert "Voltage vs Temperature" not in charts
assert "Results Summary" in charts
def test_generate_all_no_results(
self,
generator: ChartGenerator,
sample_run: TestRun,
voltage_measurements: pd.DataFrame,
) -> None:
"""Test generate_all with no results."""
charts = generator.generate_all(sample_run, [], voltage_measurements)
# Should only have voltage chart
assert "Voltage vs Temperature" in charts
assert "Results Summary" not in charts
def test_generate_all_empty(
self, generator: ChartGenerator, sample_run: TestRun
) -> None:
"""Test generate_all with no data."""
charts = generator.generate_all(sample_run, [], None)
# Should be empty
assert charts == {}
def test_matplotlib_lazy_load(self) -> None:
"""Test that matplotlib is lazy loaded."""
generator = ChartGenerator()
# _plt should be None before first use
assert generator._plt is None
# After calling _get_matplotlib, it should be loaded
plt, mpl = generator._get_matplotlib()
assert generator._plt is not None
assert plt is not None
def test_dpi_affects_output_size(self) -> None:
"""Test that higher DPI produces larger output."""
low_dpi = ChartGenerator(dpi=50)
high_dpi = ChartGenerator(dpi=150)
results = [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="test",
value=1.0,
unit="X",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
),
]
low_chart = low_dpi.generate_results_bar_chart(results)
high_chart = high_dpi.generate_results_bar_chart(results)
# Higher DPI should produce larger image
assert len(high_chart) > len(low_chart)

View File

@@ -0,0 +1,221 @@
"""Unit tests for HTML renderer."""
from datetime import datetime
import pytest
from py_dvt_ate.data.models import TestResult, TestRun, TestStatus
from py_dvt_ate.reporting.models import ReportConfig, ReportData
from py_dvt_ate.reporting.renderers.html import HTMLRenderer
class TestHTMLRenderer:
"""Tests for HTMLRenderer class."""
@pytest.fixture
def renderer(self) -> HTMLRenderer:
"""Create an HTML renderer instance."""
return HTMLRenderer()
@pytest.fixture
def sample_run(self) -> TestRun:
"""Create a sample test run."""
return TestRun(
id="12345678-1234-1234-1234-123456789abc",
test_name="tempco",
started_at=datetime(2024, 1, 15, 10, 30, 0),
completed_at=datetime(2024, 1, 15, 10, 35, 0),
status=TestStatus.PASSED,
config_json='{"temperatures": [-40, 25, 85], "input_voltage": 5.0}',
operator="test_user",
description="Temperature coefficient characterisation test",
)
@pytest.fixture
def sample_results(self) -> list[TestResult]:
"""Create sample test results."""
return [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="tempco",
value=45.0,
unit="ppm/C",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
lower_limit=None,
upper_limit=100.0,
),
TestResult(
id="result-2",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="output_voltage_25c",
value=3.3001,
unit="V",
measured_at=datetime(2024, 1, 15, 10, 33, 0),
lower_limit=3.2,
upper_limit=3.4,
),
]
@pytest.fixture
def sample_report_data(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> ReportData:
"""Create sample report data."""
return ReportData(
run=sample_run,
results=sample_results,
config=ReportConfig(company_name="Test Company"),
)
def test_render_produces_html(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render produces valid HTML output."""
html = renderer.render(sample_report_data)
assert isinstance(html, str)
assert html.startswith("<!DOCTYPE html>")
assert "</html>" in html
def test_render_includes_title(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render includes the test name in title."""
html = renderer.render(sample_report_data)
assert "<title>" in html
assert "tempco" in html
def test_render_includes_company_name(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render includes the company name."""
html = renderer.render(sample_report_data)
assert "Test Company" in html
def test_render_includes_results_table(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render includes results in a table."""
html = renderer.render(sample_report_data)
# Check for parameter names in output
assert "tempco" in html
assert "output_voltage_25c" in html
# Check for values
assert "45.000000" in html
assert "3.300100" in html
def test_render_includes_pass_status(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render shows pass status badges."""
html = renderer.render(sample_report_data)
# Check for PASS badge (results should pass)
assert "PASS" in html
def test_render_includes_run_metadata(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render includes run metadata."""
html = renderer.render(sample_report_data)
assert "12345678-1234-1234-1234-123456789abc" in html
assert "test_user" in html
assert "Temperature coefficient characterisation test" in html
def test_render_includes_css(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render includes CSS styles."""
html = renderer.render(sample_report_data)
# Check for some CSS from styles.css
assert "<style>" in html
assert "@page" in html or "font-family" in html
def test_render_includes_configuration(
self, renderer: HTMLRenderer, sample_report_data: ReportData
) -> None:
"""Test that render includes test configuration."""
html = renderer.render(sample_report_data)
# Check for config values (formatted JSON)
assert "temperatures" in html
assert "input_voltage" in html
def test_render_with_charts(
self, renderer: HTMLRenderer, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test that render includes chart images."""
# Create sample base64 chart data
charts = {
"Test Chart": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
data = ReportData(
run=sample_run,
results=sample_results,
charts=charts,
config=ReportConfig(),
)
html = renderer.render(data)
# Check for chart section and base64 image
assert "Charts" in html
assert "data:image/png;base64," in html
def test_render_empty_results(
self, renderer: HTMLRenderer, sample_run: TestRun
) -> None:
"""Test rendering with no results."""
data = ReportData(
run=sample_run,
results=[],
config=ReportConfig(),
)
html = renderer.render(data)
# Should still produce valid HTML
assert "<!DOCTYPE html>" in html
assert "No results recorded" in html
def test_css_is_cached(self, renderer: HTMLRenderer) -> None:
"""Test that CSS content is cached after first load."""
# Access CSS twice
css1 = renderer._load_css()
css2 = renderer._load_css()
# Should be the same object (cached)
assert css1 is css2
assert len(css1) > 0
def test_render_formats_limits(
self, renderer: HTMLRenderer, sample_run: TestRun
) -> None:
"""Test that limits are properly formatted."""
results = [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="test_param",
value=50.0,
unit="units",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
lower_limit=10.0,
upper_limit=100.0,
),
]
data = ReportData(run=sample_run, results=results, config=ReportConfig())
html = renderer.render(data)
# Check limits are formatted
assert "10.000000" in html
assert "100.000000" in html

View File

@@ -0,0 +1,208 @@
"""Unit tests for reporting data models."""
from datetime import datetime
from pathlib import Path
import pandas as pd
import pytest
from py_dvt_ate.data.models import TestResult, TestRun, TestStatus
from py_dvt_ate.reporting.models import ReportConfig, ReportData
class TestReportConfig:
"""Tests for ReportConfig dataclass."""
def test_default_values(self) -> None:
"""Test default configuration values."""
config = ReportConfig()
assert config.company_name == "py_dvt_ate"
assert config.logo_path is None
assert config.include_charts is True
assert config.chart_dpi == 150
def test_custom_values(self) -> None:
"""Test configuration with custom values."""
config = ReportConfig(
company_name="Test Company",
logo_path=Path("/path/to/logo.png"),
include_charts=False,
chart_dpi=300,
)
assert config.company_name == "Test Company"
assert config.logo_path == Path("/path/to/logo.png")
assert config.include_charts is False
assert config.chart_dpi == 300
class TestReportData:
"""Tests for ReportData dataclass."""
@pytest.fixture
def sample_run(self) -> TestRun:
"""Create a sample test run."""
return TestRun(
id="12345678-1234-1234-1234-123456789abc",
test_name="tempco",
started_at=datetime(2024, 1, 15, 10, 30, 0),
completed_at=datetime(2024, 1, 15, 10, 35, 0),
status=TestStatus.PASSED,
config_json='{"temperatures": [-40, 25, 85]}',
operator="test_user",
description="Test description",
)
@pytest.fixture
def sample_results(self) -> list[TestResult]:
"""Create sample test results."""
return [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="tempco",
value=45.0,
unit="ppm/C",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
lower_limit=None,
upper_limit=100.0,
),
TestResult(
id="result-2",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="output_voltage_25c",
value=3.3001,
unit="V",
measured_at=datetime(2024, 1, 15, 10, 33, 0),
lower_limit=3.2,
upper_limit=3.4,
),
]
def test_basic_report_data(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test creating basic report data."""
data = ReportData(run=sample_run, results=sample_results)
assert data.run == sample_run
assert data.results == sample_results
assert data.measurements is None
assert data.charts == {}
def test_passed_count(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test passed_count property."""
data = ReportData(run=sample_run, results=sample_results)
# Both results should pass (within limits)
assert data.passed_count == 2
def test_failed_count(self, sample_run: TestRun) -> None:
"""Test failed_count property with failed results."""
failed_results = [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="tempco",
value=150.0, # Exceeds upper limit
unit="ppm/C",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
lower_limit=None,
upper_limit=100.0,
),
]
data = ReportData(run=sample_run, results=failed_results)
assert data.failed_count == 1
assert data.passed_count == 0
def test_overall_status_pass(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test overall_status when all tests pass."""
data = ReportData(run=sample_run, results=sample_results)
assert data.overall_status == "PASS"
def test_overall_status_fail(self, sample_run: TestRun) -> None:
"""Test overall_status when tests fail."""
failed_results = [
TestResult(
id="result-1",
test_run_id="12345678-1234-1234-1234-123456789abc",
parameter="tempco",
value=150.0, # Exceeds upper limit
unit="ppm/C",
measured_at=datetime(2024, 1, 15, 10, 35, 0),
lower_limit=None,
upper_limit=100.0,
),
]
data = ReportData(run=sample_run, results=failed_results)
assert data.overall_status == "FAIL"
def test_overall_status_error(self) -> None:
"""Test overall_status when run status is error."""
error_run = TestRun(
id="12345678-1234-1234-1234-123456789abc",
test_name="tempco",
started_at=datetime(2024, 1, 15, 10, 30, 0),
status=TestStatus.ERROR,
config_json="{}",
)
data = ReportData(run=error_run, results=[])
assert data.overall_status == "ERROR"
def test_with_measurements(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test report data with measurements DataFrame."""
measurements = pd.DataFrame(
{
"timestamp": [0.0, 1.0, 2.0],
"parameter": ["output_voltage", "output_voltage", "output_voltage"],
"value": [3.30, 3.31, 3.30],
"unit": ["V", "V", "V"],
"temperature": [25.0, 25.0, 25.0],
}
)
data = ReportData(
run=sample_run, results=sample_results, measurements=measurements
)
assert data.measurements is not None
assert len(data.measurements) == 3
def test_with_charts(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test report data with chart images."""
charts = {
"Voltage vs Temperature": "base64_encoded_image_data",
"Results Summary": "another_base64_image",
}
data = ReportData(run=sample_run, results=sample_results, charts=charts)
assert len(data.charts) == 2
assert "Voltage vs Temperature" in data.charts
def test_with_custom_config(
self, sample_run: TestRun, sample_results: list[TestResult]
) -> None:
"""Test report data with custom configuration."""
config = ReportConfig(company_name="Test Company", include_charts=False)
data = ReportData(run=sample_run, results=sample_results, config=config)
assert data.config.company_name == "Test Company"
assert data.config.include_charts is False

266
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,266 @@
"""Tests for configuration loading and validation."""
from pathlib import Path
from typing import Any
import pytest
import yaml
from pydantic import ValidationError
from py_dvt_ate.app.config import (
APIConfig,
AppConfig,
ChamberConfig,
DashboardConfig,
DataConfig,
DUTConfig,
DUTParameters,
InstrumentsConfig,
LoggingConfig,
PhysicsConfig,
PyVISAConfig,
SimulatorConfig,
ThermalConfig,
load_config,
)
def test_default_config_values() -> None:
"""Test that default configuration values are correct."""
config = AppConfig()
assert config.instruments.backend == "simulator"
assert config.instruments.simulator.host == "localhost"
assert config.instruments.simulator.thermal_chamber_port == 5001
assert config.physics.update_rate_hz == 100.0
assert config.physics.thermal.chamber_time_constant_s == 30.0
assert config.physics.thermal.theta_jc == 15.0
assert config.dut.model == "ldo"
assert config.dut.parameters.nominal_output_voltage == 3.3
assert config.dut.parameters.tempco_ppm_per_c == 50.0
assert config.data.database_path == "./data/py_dvt_ate.db"
assert config.logging.level == "INFO"
assert config.dashboard.enabled is True
assert config.api.enabled is False
def test_load_config_with_defaults_only() -> None:
"""Test loading config without a file uses defaults."""
config = load_config(None)
assert config.instruments.backend == "simulator"
assert config.physics.update_rate_hz == 100.0
def test_load_config_from_file(tmp_path: Path) -> None:
"""Test loading configuration from YAML file."""
config_file = tmp_path / "test_config.yaml"
config_data = {
"instruments": {"backend": "pyvisa"},
"physics": {"update_rate_hz": 50.0},
"dut": {"model": "custom_ldo"},
}
with config_file.open("w") as f:
yaml.dump(config_data, f)
config = load_config(config_file)
assert config.instruments.backend == "pyvisa"
assert config.physics.update_rate_hz == 50.0
assert config.dut.model == "custom_ldo"
# Defaults still apply
assert config.instruments.simulator.host == "localhost"
def test_load_config_partial_override(tmp_path: Path) -> None:
"""Test that partial config overrides work correctly."""
config_file = tmp_path / "partial.yaml"
config_data = {
"physics": {
"thermal": {
"theta_jc": 20.0,
# Other thermal params should use defaults
}
}
}
with config_file.open("w") as f:
yaml.dump(config_data, f)
config = load_config(config_file)
# Overridden value
assert config.physics.thermal.theta_jc == 20.0
# Default values
assert config.physics.thermal.theta_ca == 5.0
assert config.physics.thermal.chamber_time_constant_s == 30.0
def test_load_config_missing_file() -> None:
"""Test that loading from missing file raises FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="Configuration file not found"):
load_config("nonexistent.yaml")
def test_load_config_invalid_yaml(tmp_path: Path) -> None:
"""Test that malformed YAML raises an error."""
config_file = tmp_path / "invalid.yaml"
config_file.write_text("invalid: yaml: content: [\n")
with pytest.raises(yaml.YAMLError):
load_config(config_file)
def test_load_config_validation_error(tmp_path: Path) -> None:
"""Test that invalid configuration raises ValidationError."""
config_file = tmp_path / "invalid_config.yaml"
config_data = {
"instruments": {"backend": "invalid_backend"}, # Not in Literal["simulator", "pyvisa"]
}
with config_file.open("w") as f:
yaml.dump(config_data, f)
with pytest.raises(ValidationError):
load_config(config_file)
def test_env_override_simple(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test environment variable override for simple values."""
config_file = tmp_path / "config.yaml"
config_data: dict[str, Any] = {}
with config_file.open("w") as f:
yaml.dump(config_data, f)
monkeypatch.setenv("PYDVTATE__INSTRUMENTS__BACKEND", "pyvisa")
monkeypatch.setenv("PYDVTATE__PHYSICS__UPDATE_RATE_HZ", "200.0")
config = load_config(config_file)
assert config.instruments.backend == "pyvisa"
assert config.physics.update_rate_hz == 200.0
def test_env_override_nested(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test environment variable override for nested values."""
config_file = tmp_path / "config.yaml"
config_data: dict[str, Any] = {}
with config_file.open("w") as f:
yaml.dump(config_data, f)
monkeypatch.setenv("PYDVTATE__INSTRUMENTS__SIMULATOR__HOST", "192.168.1.100")
monkeypatch.setenv("PYDVTATE__INSTRUMENTS__SIMULATOR__THERMAL_CHAMBER_PORT", "6001")
monkeypatch.setenv("PYDVTATE__PHYSICS__THERMAL__THETA_JC", "25.0")
config = load_config(config_file)
assert config.instruments.simulator.host == "192.168.1.100"
assert config.instruments.simulator.thermal_chamber_port == 6001
assert config.physics.thermal.theta_jc == 25.0
def test_env_override_types(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that environment variables are parsed to correct types."""
config_file = tmp_path / "config.yaml"
config_data: dict[str, Any] = {}
with config_file.open("w") as f:
yaml.dump(config_data, f)
monkeypatch.setenv("PYDVTATE__DASHBOARD__ENABLED", "false") # bool
monkeypatch.setenv("PYDVTATE__DASHBOARD__PORT", "9000") # int
monkeypatch.setenv("PYDVTATE__PHYSICS__UPDATE_RATE_HZ", "75.5") # float
config = load_config(config_file)
assert config.dashboard.enabled is False
assert config.dashboard.port == 9000
assert config.physics.update_rate_hz == 75.5
def test_env_override_precedence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that environment variables override file values."""
config_file = tmp_path / "config.yaml"
config_data = {"physics": {"update_rate_hz": 50.0}}
with config_file.open("w") as f:
yaml.dump(config_data, f)
monkeypatch.setenv("PYDVTATE__PHYSICS__UPDATE_RATE_HZ", "150.0")
config = load_config(config_file)
# Environment variable should win
assert config.physics.update_rate_hz == 150.0
def test_env_variables_ignored_without_prefix(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that environment variables without prefix are ignored."""
config_file = tmp_path / "config.yaml"
config_data: dict[str, Any] = {}
with config_file.open("w") as f:
yaml.dump(config_data, f)
# These should be ignored
monkeypatch.setenv("BACKEND", "pyvisa")
monkeypatch.setenv("UPDATE_RATE_HZ", "200.0")
config = load_config(config_file)
# Should use defaults
assert config.instruments.backend == "simulator"
assert config.physics.update_rate_hz == 100.0
def test_simulator_config_defaults() -> None:
"""Test SimulatorConfig default values."""
config = SimulatorConfig()
assert config.host == "localhost"
assert config.thermal_chamber_port == 5001
assert config.power_supply_port == 5002
assert config.multimeter_port == 5003
def test_pyvisa_config_defaults() -> None:
"""Test PyVISAConfig default values."""
config = PyVISAConfig()
assert config.thermal_chamber is None
assert config.power_supply is None
assert config.multimeter is None
def test_complete_config_structure() -> None:
"""Test that all config sections can be instantiated."""
config = AppConfig(
instruments=InstrumentsConfig(
backend="pyvisa",
simulator=SimulatorConfig(host="192.168.1.1"),
pyvisa=PyVISAConfig(thermal_chamber="TCPIP::192.168.1.10::INSTR"),
),
physics=PhysicsConfig(
update_rate_hz=50.0,
thermal=ThermalConfig(theta_jc=20.0),
chamber=ChamberConfig(ramp_rate_c_per_min=5.0),
),
dut=DUTConfig(
model="custom", parameters=DUTParameters(nominal_output_voltage=5.0)
),
data=DataConfig(database_path="/tmp/test.db"),
logging=LoggingConfig(level="DEBUG"),
dashboard=DashboardConfig(enabled=False),
api=APIConfig(enabled=True, port=9000),
)
assert config.instruments.backend == "pyvisa"
assert config.physics.update_rate_hz == 50.0
assert config.dut.parameters.nominal_output_voltage == 5.0
assert config.api.port == 9000

Some files were not shown because too many files have changed in this diff Show More