Refactor: rename SimDUT, restructure DUT simulation and test flow

This commit is contained in:
2025-07-21 15:57:15 -06:00
parent 27064417dd
commit 4a1f943126
28 changed files with 750 additions and 5 deletions

40
.gitignore vendored
View File

@@ -1,3 +1,39 @@
.vscode/ # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.pyc *.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
build/
dist/
*.egg-info/
# Virtual environments
.venv/
env/
venv/
# Pytest cache
.pytest_cache/
# Test results and logs
*.log
*.csv
*.html
# VS Code settings
.vscode/
# Jupyter and IPython
.ipynb_checkpoints/
.profile
# Sphinx documentation build
docs/_build/
# OS files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,13 @@
# docs/Makefile
SPHINXBUILD = sphinx-build
SOURCEDIR = .
BUILDDIR = _build
.PHONY: html clean
html:
$(SPHINXBUILD) -b html $(SOURCEDIR) $(BUILDDIR)/html
clean:
rm -rf $(BUILDDIR)/*

View File

@@ -0,0 +1,76 @@
# Hardware Test Framework Template
A reusable, Python-based testing framework designed for validating electronic hardware and DUTs. Built around `pytest`, this template supports serial, SCPI, and simulated devices, with structured output for automated test reporting.
---
## Features
- ✅ Pytest integration for structured and repeatable test execution
- ⚡ Interfaces with bench equipment via SCPI or serial
- 🔄 Optional simulator backend for offline development
- 📊 HTML and CSV test reports
- 🧩 Modular architecture: easy to extend for new instruments or DUTs
---
## Project Structure
src/
interfaces/ # Abstract communication backends (SCPI, Serial, Dummy)
instruments/ # Power supplies, eloads, DUT control logic
utils/ # Logging, timing, CSV writing
config/ # TOML/YAML config for sim/real hardware
tests/ # Pytest test cases
docs/ # Sphinx documentation
---
## Getting Started
# Set up venv (optional but recommended)
python -m venv .venv
source .venv/bin/activate
or
.venv\Scripts\Activate.ps1 on Windows powershell
# Install dependencies
pip install -r requirements.txt
# Run tests
pytest
or
pytest --sim
---
## Documentation
To build HTML docs:
cd docs
make html
in windows:
cd docs
sphinx-build -b html . _build/html
Generated docs will be in docs/_build/html/index.html
---
## License
MIT
---
## Author
Nathan Corwin — nathan@corwin.life

View File

@@ -0,0 +1,25 @@
import pytest
from src.instruments.real_dut import RealDUT
from src.instruments.simulated_dut import SimulatedDUT
from src.instruments.power_supply import PowerSupply
from src.interfaces.dummy import DummyBackend
def pytest_addoption(parser):
parser.addoption("--sim", action="store_true", help="Use simulation mode")
@pytest.fixture
def sim_mode(request):
return request.config.getoption("--sim")
@pytest.fixture
def dut(sim_mode):
if sim_mode:
return SimulatedDUT()
return RealDUT()
@pytest.fixture
def power_supply(sim_mode):
if sim_mode:
return PowerSupply(DummyBackend())
# TODO: Replace with real SCPI interface when ready
raise NotImplementedError("Real power supply interface not yet implemented")

43
docs/_static/custom.css vendored Normal file
View File

@@ -0,0 +1,43 @@
/* custom.css: Modernized Sphinx Theme Tweaks */
body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
background-color: #fefefe;
color: #333;
max-width: 1000px;
margin: auto;
padding: 2em;
}
h1, h2, h3, h4 {
color: #005f73;
}
a {
color: #0a9396;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code, pre {
background-color: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: Consolas, monospace;
color: #1e1e1e;
}
.rst-content table.docutils {
border-collapse: collapse;
border: 1px solid #ccc;
}
.rst-content table.docutils th,
.rst-content table.docutils td {
border: 1px solid #ccc;
padding: 6px 12px;
}

View File

@@ -0,0 +1,4 @@
Architecture
============
(coming soon)

View File

@@ -0,0 +1,20 @@
# docs/conf.py
import os
import sys
sys.path.insert(0, os.path.abspath('..'))
project = 'Hardware Test Framework'
author = 'Nathan Corwin'
release = '0.1'
extensions = [
'sphinx.ext.autodoc',
]
templates_path = ['_templates']
exclude_patterns = []
html_theme = 'alabaster'
html_static_path = ['_static']
html_css_files = ['custom.css']

View File

@@ -0,0 +1,35 @@
Getting Started
===============
This guide helps you bootstrap your first test project using the framework.
Setup
-----
1. Clone the repo:
.. code-block:: bash
git clone ssh://git@git.corwin.life:23231/hw-test-template.git
2. Create a virtual environment and install dependencies:
.. code-block:: bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
3. Run a simulation-mode test:
.. code-block:: bash
pytest --sim
Next Steps
----------
- Edit or extend the `SimDUT` class for your DUT logic
- Add custom test cases in `tests/`
- Build docs using:
.. code-block:: bash
sphinx-build -b html . _build/html

31
docs/hardware.rst Normal file
View File

@@ -0,0 +1,31 @@
Real Hardware Integration
=========================
This page describes how to implement real instrument interfaces.
Backends
--------
Interfaces expected:
- Serial-based DUT communication
- SCPI commands over USB or LAN
- Future support for Modbus, I2C, or CAN (if needed)
Adding a Real DUT
------------------
1. Create a `RealDUT` class in `src/instruments/dut_controller.py`
2. Match the interface used in `SimDUT`
3. Uncomment the fixture in `conftest.py` to enable
.. code-block:: python
# return RealDUT(port="/dev/ttyUSB0")
Safety Tips
-----------
- Use proper delays when enabling outputs
- Confirm settings before triggering loads
- Log all commands during development

View File

@@ -0,0 +1,24 @@
Welcome to the Hardware Test Framework!
=======================================
.. toctree::
:maxdepth: 2
:caption: Contents:
getting_started
usage
architecture
simulation
hardware
modules
----
Overview
--------
This project is a reusable Python test harness for validating electronic hardware designs using Pytest, SCPI/serial interfaces, and optional simulation mode.
* Easy to extend
* Supports both bench instruments and DUT interaction
* Includes automatic HTML test reporting

22
docs/modules.rst Normal file
View File

@@ -0,0 +1,22 @@
Module Reference
================
This section provides an overview of Python modules in the framework.
Instrument Interfaces
---------------------
.. automodule:: src.instruments.dut_controller
:members:
Utilities
---------
.. automodule:: src.utils.logger
:members:
Serial Interfaces
-----------------
.. automodule:: src.interfaces.serial
:members:

23
docs/simulation.rst Normal file
View File

@@ -0,0 +1,23 @@
Simulation Mode
===============
The simulation backend allows you to test framework logic without connecting real hardware.
How It Works
------------
Fixtures like `dut` use mock classes defined in `src/instruments/dut_controller.py`.
Example Methods:
- `get_version()`
- `read_temperature()`
- `set_output_enabled()`
Extending Sim Mode
------------------
You can expand simulation by:
- Creating additional dummy instruments
- Returning randomized or patterned values
- Adding logs to mimic hardware responses

31
docs/usage.rst Normal file
View File

@@ -0,0 +1,31 @@
Usage Guide
===========
This section describes how to set up and run the test framework.
Quick Start
-----------
1. Clone the repo and create a virtual environment:
.. code-block:: bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
2. Run tests in simulation mode:
.. code-block:: bash
pytest --sim
3. View the report:
- Open `report.html` in your browser.
Switching Between Sim and Hardware
----------------------------------
Use the `--sim` flag to control which backend is used.
The system defaults to skipping hardware tests

View File

@@ -0,0 +1,17 @@
[pytest]
# Add command-line options by default
addopts = --tb=short --strict-markers --html=report.html --self-contained-html
# Optional: set default HTML report location (if you use pytest-html)
# addopts = --tb=short --strict-markers --html=report.html
# Markers you plan to use in test code
markers =
sim: mark test to run in simulation mode only
hardware: mark test that requires real hardware
slow: mark test as slow (e.g., long soak or ramp tests)
sanity: mark test as quick smoke/sanity check
# Logging settings (optional)
log_cli = true
log_level = INFO

View File

@@ -1,5 +1,10 @@
# Core testing
pytest pytest
tomli pytest-html
# Documentation
sphinx sphinx
#pyvisa
#pyserial # Instrumentation (comment in when needed)
pyvisa
pyserial

View File

@@ -0,0 +1,17 @@
# src/instruments/dut_controller.py
from src.utils.logger import init_logger
from src.instruments.simulated_dut import SimulatedDUT
# from src.instruments.real_dut import RealDUT # Enable when ready
logger = init_logger(__name__)
def get_dut(sim: bool = False):
"""Returns a DUT instance based on sim mode."""
if sim:
logger.info("Using SimDUT")
return SimulatedDUT()
else:
logger.info("Using RealDUT")
raise NotImplementedError("Real DUT is not yet implemented.")
# return RealDUT() # Uncomment when ready

View File

@@ -0,0 +1,59 @@
"""
Power Supply controller
This module provides a high-level interface to control a programmable power supply.
It supports setting voltage, enabling/disabling output, and measuring values from the supply.
Communication is delegated to a backend (e.g., SCPI, dummy simulator).
"""
import logging
logger = logging.getLogger(__name__)
class PowerSupply:
def __init__(self, comm):
"""
Initialize the power supply interface.
Args:
comm: A communication backend object that implements `write()` and `query()` methods.
"""
self.comm = comm
logger.info("PowerSupply interface initialized.")
def set_voltage(self, voltage: float):
"""
Set the output voltage of the power supply.
Args:
voltage: The desired voltage in volts.
"""
logger.debug(f"Setting voltage to {voltage:.2f}V")
self.comm.write(f"VOLT {voltage}")
def enable_output(self, enable: bool):
"""
Enable or disable the power supply output.
Args:
enable: True to enable output, False to disable.
"""
command = "OUTP ON" if enable else "OUTP OFF"
logger.debug(f"Setting output state: {command}")
self.comm.write(command)
def measure_voltage(self) -> float:
"""
Measure and return the current output voltage.
Returns:
The measured voltage in volts.
"""
response = self.comm.query("MEAS:VOLT?")
logger.debug(f"Measured voltage response: {response}")
try:
return float(response.strip())
except ValueError:
logger.error(f"Invalid voltage response: {response}")
return float('nan')

View File

@@ -0,0 +1,22 @@
# src/instruments/real_dut.py
from src.utils.logger import init_logger
logger = init_logger(__name__)
class RealDUT:
"""Real Device Under Test (stub)."""
def __init__(self, port="/dev/ttyUSB0"):
self.port = port
logger.info(f"Connecting to real DUT on port {port}")
# Set up serial or network connection here
def get_version(self):
raise NotImplementedError("Real DUT not implemented yet.")
def read_temperature(self):
raise NotImplementedError("Real DUT not implemented yet.")
def set_output_enabled(self, enabled: bool):
raise NotImplementedError("Real DUT not implemented yet.")

View File

@@ -0,0 +1,27 @@
# src/instruments/simulated_dut.py
from src.utils.logger import init_logger
logger = init_logger(__name__)
class SimulatedDUT:
"""Simulated Device Under Test."""
def __init__(self):
logger.info("SimDUT initialized")
self.version = "sim-1.0.0"
def get_version(self):
"""Return simulated firmware version string."""
return self.version
def read_temperature(self):
"""Simulated temperature sensor readout."""
logger.debug("SimDUT returning temperature")
return 42.5
def set_output_enabled(self, enabled: bool):
"""Simulated control for DUT output."""
state = "enabled" if enabled else "disabled"
logger.debug(f"SimDUT setting output: {state}")
return f"Output {state}"

View File

@@ -0,0 +1,51 @@
"""
Dummy backend for instrument simulation.
This backend emulates SCPI-compatible instruments by printing or returning canned responses.
Used for simulation/testing when hardware is not connected.
"""
import logging
logger = logging.getLogger(__name__)
class DummyBackend:
def __init__(self):
self.state = {
"VOLT": 12.0,
"OUTP": False,
}
logger.info("Initialized DummyBackend")
def write(self, command: str):
logger.debug(f"[SIM WRITE] {command}")
parts = command.strip().split()
if not parts:
return
cmd = parts[0].upper()
if cmd == "VOLT" and len(parts) == 2:
try:
self.state["VOLT"] = float(parts[1])
logger.info(f"[SIM] Voltage set to {self.state['VOLT']} V")
except ValueError:
logger.warning(f"[SIM] Invalid voltage value: {parts[1]}")
elif cmd == "OUTP" and len(parts) == 2:
state = parts[1].upper()
self.state["OUTP"] = state == "ON"
logger.info(f"[SIM] Output {'enabled' if self.state['OUTP'] else 'disabled'}")
def query(self, command: str) -> str:
logger.debug(f"[SIM QUERY] {command}")
cmd = command.strip().upper()
if cmd == "MEAS:VOLT?":
# Simulated slight fluctuation
voltage = self.state["VOLT"] + 0.01
return f"{voltage:.2f}"
return "0"

View File

@@ -0,0 +1,41 @@
"""
SCPI communication backend for test instruments using VISA.
This module provides a communication layer to talk to SCPI-compatible devices via USB, GPIB, or TCP/IP.
"""
import logging
logger = logging.getLogger(__name__)
try:
import pyvisa
except ImportError:
pyvisa = None
logger.warning("pyvisa not available. SCPI interface will not work until it's installed.")
class SCPIDevice:
def __init__(self, resource_name: str, timeout: int = 2000):
"""
Initialize SCPI communication with the specified VISA resource.
Args:
resource_name: VISA resource string (e.g., "USB0::0x1234::0x5678::INSTR").
timeout: Communication timeout in milliseconds.
"""
if not pyvisa:
raise ImportError("pyvisa is required for SCPI backend.")
self.rm = pyvisa.ResourceManager()
self.instrument = self.rm.open_resource(resource_name)
self.instrument.timeout = timeout
logger.info(f"SCPI instrument initialized: {resource_name}")
def write(self, command: str):
logger.debug(f"[SCPI WRITE] {command}")
self.instrument.write(command)
def query(self, command: str) -> str:
logger.debug(f"[SCPI QUERY] {command}")
return self.instrument.query(command)

View File

@@ -0,0 +1,54 @@
"""
Serial communication backend for DUTs and microcontrollers (e.g. Arduino).
Provides a simple wrapper around pyserial for consistent I/O behavior.
"""
import logging
logger = logging.getLogger(__name__)
try:
import serial
except ImportError:
serial = None
logger.warning("pyserial not available. Serial interface will not work until it's installed.")
class SerialDevice:
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
"""
Initialize serial communication with the device.
Args:
port: Serial port (e.g., COM3, /dev/ttyUSB0)
baudrate: Baud rate (default: 115200)
timeout: Read timeout in seconds (default: 1.0)
"""
if not serial:
raise ImportError("pyserial is required for SerialDevice backend.")
self.ser = serial.Serial(port=port, baudrate=baudrate, timeout=timeout)
logger.info(f"Serial port {port} opened at {baudrate} baud")
def write(self, data: str):
"""
Send a string over the serial connection.
"""
logger.debug(f"[SERIAL WRITE] {data.strip()}")
self.ser.write(data.encode('utf-8'))
def read_line(self) -> str:
"""
Read a single line from the serial connection.
"""
line = self.ser.readline().decode('utf-8').strip()
logger.debug(f"[SERIAL READ] {line}")
return line
def close(self):
"""
Close the serial port connection.
"""
self.ser.close()
logger.info("Serial port closed")

View File

@@ -0,0 +1,35 @@
# src/utils/logger.py
import logging
def init_logger(name: str = "test_framework", level: int = logging.INFO) -> logging.Logger:
"""
Initializes and returns a logger instance with a standard format.
Parameters
----------
name : str
Name of the logger.
level : int
Logging level (e.g., logging.INFO, logging.DEBUG).
Returns
-------
logging.Logger
Configured logger instance.
"""
logger = logging.getLogger(name)
logger.setLevel(level)
if not logger.handlers:
# Console handler
ch = logging.StreamHandler()
ch.setLevel(level)
# Formatter
formatter = logging.Formatter('[%(levelname)s] %(name)s: %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
return logger

14
tests/test_dut.py Normal file
View File

@@ -0,0 +1,14 @@
# tests/test_dut.py
import pytest
def test_dut_version(dut):
"""Ensure the DUT returns a version string in sim mode."""
version = dut.get_version()
assert version.startswith("sim-")
def test_dut_temperature(dut):
"""Test readback of simulated temperature."""
temp = dut.read_temperature()
assert 20 <= temp <= 100 # Acceptable range

View File

@@ -0,0 +1,20 @@
# tests/test_power_supply.py
"""
Basic functional tests for the PowerSupply
"""
def test_set_and_measure_voltage(power_supply):
test_voltage = 5.0
power_supply.set_voltage(test_voltage)
measured = power_supply.measure_voltage()
assert abs(measured - test_voltage) < 0.05
def test_output_toggle(power_supply):
power_supply.enable_output(True)
# Only works for DummyBackend which keeps state
if hasattr(power_supply.comm, "state"):
assert power_supply.comm.state["OUTP"] is True
power_supply.enable_output(False)
if hasattr(power_supply.comm, "state"):
assert power_supply.comm.state["OUTP"] is False