From 4a1f943126ac3a63d6d21f3893bd08b3fde41e12 Mon Sep 17 00:00:00 2001 From: nathan corwin Date: Mon, 21 Jul 2025 15:57:15 -0600 Subject: [PATCH] Refactor: rename SimDUT, restructure DUT simulation and test flow --- .gitignore | 40 +++++++++++++++- Makefile | 13 ++++++ README.md | 76 +++++++++++++++++++++++++++++++ conftest.py | 25 ++++++++++ docs/_static/custom.css | 43 +++++++++++++++++ docs/architecture.rst | 4 ++ docs/conf.py | 20 ++++++++ docs/getting_started.rst | 35 ++++++++++++++ docs/hardware.rst | 31 +++++++++++++ docs/index.rst | 24 ++++++++++ docs/modules.rst | 22 +++++++++ docs/simulation.rst | 23 ++++++++++ docs/usage.rst | 31 +++++++++++++ docs/usage_examples.rst | 0 pytest.ini | 17 +++++++ requirements.txt | 11 +++-- src/instruments/dut_control.py | 0 src/instruments/dut_controller.py | 17 +++++++ src/instruments/eload.py | 0 src/instruments/power_supply.py | 59 ++++++++++++++++++++++++ src/instruments/real_dut.py | 22 +++++++++ src/instruments/simulated_dut.py | 27 +++++++++++ src/interfaces/dummy.py | 51 +++++++++++++++++++++ src/interfaces/scpi.py | 41 +++++++++++++++++ src/interfaces/serial.py | 54 ++++++++++++++++++++++ src/utils/logger.py | 35 ++++++++++++++ tests/test_dut.py | 14 ++++++ tests/test_power_supply.py | 20 ++++++++ 28 files changed, 750 insertions(+), 5 deletions(-) create mode 100644 docs/_static/custom.css create mode 100644 docs/hardware.rst create mode 100644 docs/modules.rst create mode 100644 docs/simulation.rst create mode 100644 docs/usage.rst delete mode 100644 docs/usage_examples.rst delete mode 100644 src/instruments/dut_control.py create mode 100644 src/instruments/dut_controller.py delete mode 100644 src/instruments/eload.py create mode 100644 src/instruments/real_dut.py create mode 100644 src/instruments/simulated_dut.py create mode 100644 tests/test_dut.py create mode 100644 tests/test_power_supply.py diff --git a/.gitignore b/.gitignore index ecfce30..67f7333 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,39 @@ -.vscode/ +# Byte-compiled / optimized / DLL files __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 \ No newline at end of file diff --git a/Makefile b/Makefile index e69de29..e23b3be 100644 --- a/Makefile +++ b/Makefile @@ -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)/* diff --git a/README.md b/README.md index e69de29..9b3b26f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/conftest.py b/conftest.py index e69de29..8a0543e 100644 --- a/conftest.py +++ b/conftest.py @@ -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") \ No newline at end of file diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..6fe3596 --- /dev/null +++ b/docs/_static/custom.css @@ -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; +} diff --git a/docs/architecture.rst b/docs/architecture.rst index e69de29..12116e8 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -0,0 +1,4 @@ +Architecture +============ + +(coming soon) diff --git a/docs/conf.py b/docs/conf.py index e69de29..d4d98da 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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'] diff --git a/docs/getting_started.rst b/docs/getting_started.rst index e69de29..c242f03 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -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 diff --git a/docs/hardware.rst b/docs/hardware.rst new file mode 100644 index 0000000..5bac701 --- /dev/null +++ b/docs/hardware.rst @@ -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 + diff --git a/docs/index.rst b/docs/index.rst index e69de29..3398087 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 0000000..d978c45 --- /dev/null +++ b/docs/modules.rst @@ -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: diff --git a/docs/simulation.rst b/docs/simulation.rst new file mode 100644 index 0000000..6a433da --- /dev/null +++ b/docs/simulation.rst @@ -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 + diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..9cf7d47 --- /dev/null +++ b/docs/usage.rst @@ -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 diff --git a/docs/usage_examples.rst b/docs/usage_examples.rst deleted file mode 100644 index e69de29..0000000 diff --git a/pytest.ini b/pytest.ini index e69de29..5726d14 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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 diff --git a/requirements.txt b/requirements.txt index 9b23158..d33b4e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,10 @@ +# Core testing pytest -tomli +pytest-html + +# Documentation sphinx -#pyvisa -#pyserial \ No newline at end of file + +# Instrumentation (comment in when needed) +pyvisa +pyserial diff --git a/src/instruments/dut_control.py b/src/instruments/dut_control.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/instruments/dut_controller.py b/src/instruments/dut_controller.py new file mode 100644 index 0000000..69c5833 --- /dev/null +++ b/src/instruments/dut_controller.py @@ -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 diff --git a/src/instruments/eload.py b/src/instruments/eload.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/instruments/power_supply.py b/src/instruments/power_supply.py index e69de29..e3032f3 100644 --- a/src/instruments/power_supply.py +++ b/src/instruments/power_supply.py @@ -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') diff --git a/src/instruments/real_dut.py b/src/instruments/real_dut.py new file mode 100644 index 0000000..067769a --- /dev/null +++ b/src/instruments/real_dut.py @@ -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.") diff --git a/src/instruments/simulated_dut.py b/src/instruments/simulated_dut.py new file mode 100644 index 0000000..f721448 --- /dev/null +++ b/src/instruments/simulated_dut.py @@ -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}" diff --git a/src/interfaces/dummy.py b/src/interfaces/dummy.py index e69de29..2f5f9a2 100644 --- a/src/interfaces/dummy.py +++ b/src/interfaces/dummy.py @@ -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" diff --git a/src/interfaces/scpi.py b/src/interfaces/scpi.py index e69de29..0208bf2 100644 --- a/src/interfaces/scpi.py +++ b/src/interfaces/scpi.py @@ -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) diff --git a/src/interfaces/serial.py b/src/interfaces/serial.py index e69de29..07b1e4b 100644 --- a/src/interfaces/serial.py +++ b/src/interfaces/serial.py @@ -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") diff --git a/src/utils/logger.py b/src/utils/logger.py index e69de29..a9cfd51 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -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 diff --git a/tests/test_dut.py b/tests/test_dut.py new file mode 100644 index 0000000..cc61a8a --- /dev/null +++ b/tests/test_dut.py @@ -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 + diff --git a/tests/test_power_supply.py b/tests/test_power_supply.py new file mode 100644 index 0000000..7c1b9cb --- /dev/null +++ b/tests/test_power_supply.py @@ -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 \ No newline at end of file