diff --git a/.gitignore b/.gitignore index 5e08b19..bd010f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ **/__pycache__/* src/ppk2_api.egg-info build -dist \ No newline at end of file +dist!/.gitignore +.idea +*.csv +/src/ppk2_api_python.egg-info/* \ No newline at end of file diff --git a/README.md b/README.md index ed0e941..f811212 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ ## Description -The new Nordic Semiconductor's [Power Profiler Kit II (PPK 2)](https://www.nordicsemi.com/Software-and-tools/Development-Tools/Power-Profiler-Kit-2) is very useful for real time measurement of device power consumption. The official [nRF Connect Power Profiler tool](https://github.com/NordicSemiconductor/pc-nrfconnect-ppk) provides a friendly GUI with real-time data display. However there is no support for automated power monitoring. The puropose of this Python API is to enable automated power monitoring and data logging in Python applications. +The new Nordic Semiconductor's [Power Profiler Kit II (PPK 2)](https://www.nordicsemi.com/Software-and-tools/Development-Tools/Power-Profiler-Kit-2) is very useful for real time measurement of device power consumption. The official [nRF Connect Power Profiler tool](https://github.com/NordicSemiconductor/pc-nrfconnect-ppk) provides a friendly GUI with real-time data display. However, there is no support for automated power monitoring. The purpose of this Python API is to enable automated power monitoring and data logging in Python applications. + +Original description comes from webpage : https://docs.nordicsemi.com/bundle/ug_ppk2/page/UG/ppk/ppk_downloadable_content.html ![Power Profiler Kit II](https://github.com/IRNAS/ppk2-api-python/blob/master/images/power-profiler-kit-II.jpg) +## Overview + ## Features The main features of the PPK2 Python API (will) include: * All nRF Connect Power Profiler GUI functionality - In progress @@ -56,6 +60,130 @@ ppk2_test.stop_measuring() ``` +## Tools + +### Battery Emulator GUI +The `ppk2_battery_emulator_gui.py` tool provides a graphical interface for emulating battery discharge profiles. It allows you to test your device's performance under different battery conditions. + +![Battery Emulator GUI](images/battety_simulation_mode.png) + +To run the tool, execute the following command: +``` +python tools/ppk2_battery_emulator_gui.py +``` + +The emulator supports two discharge curve behaviors: + +* **S-Curve (Real Emulation Mode):** This is the default mode and simulates a realistic battery discharge profile. It consists of three stages: + 1. An initial rapid voltage drop (100% to 90% SoC). + 2. A long, stable voltage plateau (90% to 20% SoC). + 3. A final sharp voltage drop as the battery nears depletion (20% to 0% SoC). + This mode is ideal for understanding how a device will perform with an actual battery over its entire discharge cycle. + +* **Linear V-SoC Curve (Test Mode):** In this mode, the voltage decreases linearly as the State of Charge (SoC) drops. This provides a predictable, straight-line voltage drop from the start voltage to the stop voltage, which is useful for simplified testing or for scenarios where a constant rate of voltage change is required. + +### SMU PPK2 GUI +The `smu-ppk2-gui` is a graphical tool that emulates a two-channel Source/Measure Unit (SMU) using one or two Nordic PPK2s. This allows you to perform I-V curve tracing on various electronic components like diodes, transistors, and more. The tool can also export the collected data to a SPICE model for further simulation. + +![SMU PPK2 GUI](images/smu_emulator.png) + +To install and run the tool, use the following commands: +``` +cd tools/smu-ppk2-gui +pip install -r requirements.txt +python setup.py install +smu-gui +``` + +#### GUI Functions Overview + +The SMU PPK2 GUI is organized into several tabs, guiding the user through the process of component characterization: + +* **1. Configuration:** + * **Component Selection:** Choose from a predefined list of components (e.g., LED, Diode, Transistor) to load specific test parameters. + * **Use second PPK2:** A checkbox to enable or disable the second PPK2 for two-port measurements (e.g., for transistors). + * **Port PPK2 #1 / #2:** Input fields for the serial ports of the connected PPK2 devices. + * **S/N:** Displays the serial number of the connected PPK2s. + * **Auto-Detect PPK2:** Automatically scans and populates the serial port fields with detected PPK2 devices. + * **Loaded Parameters:** A text area displaying the sweep parameters (voltage ranges, steps, delay) loaded for the selected component. + +* **2. Connection Diagram:** + * This tab dynamically displays a connection diagram based on the selected component and whether a second PPK2 is enabled, guiding the user on how to physically connect the component to the PPK2(s). + +* **3. Test and Run:** + * **Temp:** Displays the current temperature (if a sensor is available). + * **Start full test:** Initiates the I-V sweep measurement based on the configured parameters. + +* **4. Results Plot:** + * Displays the measured I-V characteristics graphically. The plot shows Voltage on the X-axis and Current on the Y-axis. If two PPK2s are used, both I-V curves will be plotted. + +#### Exported Data Formats + +The tool allows exporting measurement results in two formats: + +* **CSV File Format:** + The CSV file contains the raw measurement data in a tabular format, suitable for analysis in spreadsheets or other data processing tools. The columns are: + * `V1 [V]`: Voltage measured by PPK2 #1 (Source/Measure Unit 1). + * `I1 [A]`: Current measured by PPK2 #1. + * `V2 [V]`: Voltage measured by PPK2 #2 (Source/Measure Unit 2), if enabled. Otherwise, 0. + * `I2 [A]`: Current measured by PPK2 #2, if enabled. Otherwise, 0. + * `Temp [°C]`: Temperature recorded during the measurement. + * `Power [mW]`: Calculated power (V1 * I1) in milliwatts. + +* **SPICE (.lib) File Format:** + The SPICE `.lib` file generates a subcircuit model of the characterized component, which can be directly imported into SPICE simulators (e.g., LTSpice, ngspice). The model uses a Voltage-Controlled Current Source (G-source) with a Piecewise Linear (PWL) function to represent the measured I-V curve. + + The general structure of the `.lib` file is as follows: + ```spice + * SPICE Library Model for [Component Name] – Measured [Timestamp] + * Model generated by PPK2 SMU Export Tool + + .SUBCKT [Component_Name_Sanitized] 1 2 + * G_MEAS implements the measured I(V) characteristic using a PWL function. + * G_MEAS: Node 1 is the positive terminal, Node 2 is the negative terminal. + * Control voltage is V(1, 2). + G_MEAS 1 2 VALUE={ + + PWL(V(1, 2), + + [V_point_1], [I_point_1], + + [V_point_2], [I_point_2], + + ... + + [V_point_N], [I_point_N] + + ) + } + .ENDS [Component_Name_Sanitized] + + .lib [filename.lib] [Component_Name_Sanitized] + ``` + Each `[V_point], [I_point]` pair corresponds to a measured voltage and current, allowing the SPICE simulator to accurately model the component's behavior based on the experimental data. + +### What is a Source/Measure Unit (SMU)? +A Source/Measure Unit (SMU) is a highly versatile electronic test instrument that combines the capabilities of a precision voltage source, a precision current source, a voltage meter, and a current meter into a single, synchronized unit. This unique combination allows an SMU to simultaneously supply a voltage or current and measure the resulting current or voltage, respectively. + +SMUs are indispensable in various applications, particularly in: + +* **I-V Characterization:** Performing current-voltage sweeps to understand the electrical behavior of components (e.g., diodes, transistors, resistors) and materials. This involves applying a range of voltages and measuring the corresponding currents, or vice-versa. +* **Parametric Testing:** Precisely measuring critical electrical parameters of semiconductor devices, integrated circuits, and other electronic components to ensure they meet design specifications. +* **Device and Materials Research:** Characterizing novel materials, sensors, and emerging electronic devices where precise control and measurement of electrical stimuli are crucial. +* **Reliability Testing:** Stressing components with controlled electrical signals to evaluate their long-term performance and identify potential failure mechanisms. + +Compared to separate instruments, an SMU offers superior synchronization between sourcing and measuring, higher accuracy, and often a wider dynamic range, making it ideal for sensitive measurements and automated test setups. + +### PPK2 vs. Lab-Grade SMU Comparison + +The following table provides a general comparison between the Nordic PPK2 and a typical lab-grade SMU like the Keysight B2900 series. + +| Feature | Nordic PPK2 | Keysight B2900 Series (Lab SMU) | +| ------------------------ | ------------------------------------------------- | ------------------------------------------------ | +| **Primary Use Case** | IoT device power profiling and debugging | Precision DC characterization, test, and measurement | +| **Voltage Range** | 0.8V to 5V (Source Mode) | Up to 210V | +| **Current Range** | 200nA to 1A | Up to 3A (DC), 10.5A (pulsed) | +| **Resolution** | ~100nA | As low as 10fA / 100nV | +| **Sampling Rate** | 100 kS/s | Up to 200 kS/s | +| **Accuracy** | Good for typical IoT applications | High precision, suitable for characterization | +| **Cost** | ~$100 | >$5,000 | +| **Portability** | Highly portable, USB powered | Benchtop instrument | +| **Software** | nRF Connect for Desktop, Python API | Proprietary software, SCPI commands | + ## Licensing pp2-api-python is licensed under [GPL V2 license](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html). diff --git a/doc/PCA63100_Schematic_And_PCB.pdf b/doc/PCA63100_Schematic_And_PCB.pdf new file mode 100644 index 0000000..d31cb7b Binary files /dev/null and b/doc/PCA63100_Schematic_And_PCB.pdf differ diff --git a/doc/pca63100-power-profiler-kit-ii---hw-files---1_0_1.zip b/doc/pca63100-power-profiler-kit-ii---hw-files---1_0_1.zip new file mode 100644 index 0000000..8b27d42 Binary files /dev/null and b/doc/pca63100-power-profiler-kit-ii---hw-files---1_0_1.zip differ diff --git a/doc/ppk2_block_diagram.svg b/doc/ppk2_block_diagram.svg new file mode 100644 index 0000000..9a582e7 --- /dev/null +++ b/doc/ppk2_block_diagram.svg @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page-1 + + + + + Nordic Blue + USB data and power supply + + + + USB data and power supply + + Nordic Blue.7 + USB power supply + + + + USB power supply + + Custom 2 + MUX + + + + + + + + + + + + + + + + + + + + MUX + + Nordic Blue.14 + Adjustable Regulator + + + + Adjustable Regulator + + Dynamic connector + + + + Dynamic connector.17 + + + + Dynamic connector.18 + + + + Custom 2.19 + MUX + + + + + + + + + + + + + + + + + + + + MUX + + Nordic Blue.25 + Power in + + + + Power in + + Dynamic connector.26 + + + + Sheet.27 + Max 5 V + + + + Max 5 V + + Nordic Lake + SoC + + + + SoC + + Dynamic connector.29 + + + + Sheet.30 + USB CDC Communication + + + + USB CDC Communication + + Dynamic connector.31 + + + + Sheet.33 + Voltage Control + + + + Voltage Control + + Dynamic connector.34 + + + + Sheet.35 + Input control + + + + Input control + + Sheet.36 + + + + + + + + + Dynamic connector.41 + + + + Sheet.42 + Temperature sensor + + + + Temperature sensor + + Dynamic connector.43 + + + + Dynamic connector.44 + + + + Sheet.46 + Voltage measurement + + + + Voltage measurement + + Dynamic connector.52 + + + + Sheet.57 + Current measurement + + + + Current measurement + + Dynamic connector.58 + + + + Nordic Blue.66 + Logic port + + + + Logic port + + Dynamic connector.74 + + + + Sheet.75 + Output control + + + + Output control + + Nordic Blue.77 + LEDs + + + + LEDs + + Nordic Blue.78 + EEPROM + + + + EEPROM + + Nordic Blue.79 + Level Shifter + + + + Level Shifter + + Dynamic connector.81 + + + + Signal, shares physical pin with other signal.225 + + + + Dynamic connector.88 + + + + Signal, shares physical pin with other signal.89 + + + + Sheet.91 + + Nordic Red.219 + + + + Sheet.40 + + + + + + Sheet.92 + + Nordic Blueslate + + + + Nordic Blue.48 + Measurement circuitry + + + + Measurement circuitry + + Nordic Blue.49 + Automatic switch circuitry + + + + Automatic switch circuitry + + + Nordic Blue.65 + DUT + + + + DUT + + Dynamic connector.68 + + + + Dynamic connector.69 + + + + Dynamic connector.67 + + + + Dynamic connector.71 + + + + Sheet.95 + 8-bit bidirectional port + + + + 8-bit bidirectional port + + Sheet.98 + 0.8 V-5.0 V + + + + 0.8 V-5.0 V + + \ No newline at end of file diff --git a/doc/ppk2_pcb1a_t.png b/doc/ppk2_pcb1a_t.png new file mode 100644 index 0000000..96bdcf5 Binary files /dev/null and b/doc/ppk2_pcb1a_t.png differ diff --git a/doc/ppk2_pcb2_t.png b/doc/ppk2_pcb2_t.png new file mode 100644 index 0000000..9bc3015 Binary files /dev/null and b/doc/ppk2_pcb2_t.png differ diff --git a/doc/ppk2_userguide_1.0.1.pdf b/doc/ppk2_userguide_1.0.1.pdf new file mode 100644 index 0000000..f92672d Binary files /dev/null and b/doc/ppk2_userguide_1.0.1.pdf differ diff --git a/example.py b/example.py index 5753754..ff7cdf2 100644 --- a/example.py +++ b/example.py @@ -1,4 +1,3 @@ - """ Basic usage of PPK2 Python API. The basic ampere mode sequence is: @@ -6,32 +5,37 @@ 2. set ampere mode 3. read stream of data """ + import time from ppk2_api.ppk2_api import PPK2_API ppk2s_connected = PPK2_API.list_devices() -if(len(ppk2s_connected) == 1): - ppk2_port = ppk2s_connected[0] - print(f'Found PPK2 at {ppk2_port}') +if len(ppk2s_connected) == 1: + ppk2_port = ppk2s_connected[0][0] + ppk2_serial = ppk2s_connected[0][1] + print(f"Found PPK2 at {ppk2_port} with serial number {ppk2_serial}") else: - print(f'Too many connected PPK2\'s: {ppk2s_connected}') + print(f"Too many connected PPK2's: {ppk2s_connected}") exit() ppk2_test = PPK2_API(ppk2_port, timeout=1, write_timeout=1, exclusive=True) ppk2_test.get_modifiers() ppk2_test.set_source_voltage(3300) -ppk2_test.use_source_meter() # set source meter mode -ppk2_test.toggle_DUT_power("ON") # enable DUT power +# set source meter mode +ppk2_test.use_source_meter() +# enable DUT power +ppk2_test.toggle_DUT_power("ON") +# start measuring +ppk2_test.start_measuring() -ppk2_test.start_measuring() # start measuring # measurements are a constant stream of bytes # the number of measurements in one sampling period depends on the wait between serial reads # it appears the maximum number of bytes received is 1024 # the sampling rate of the PPK2 is 100 samples per millisecond for i in range(0, 1000): read_data = ppk2_test.get_data() - if read_data != b'': + if read_data != b"": samples, raw_digital = ppk2_test.get_samples(read_data) print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") @@ -45,14 +49,15 @@ print() time.sleep(0.01) -ppk2_test.toggle_DUT_power("OFF") # disable DUT power - -ppk2_test.use_ampere_meter() # set ampere meter mode +# disable DUT power +ppk2_test.toggle_DUT_power("OFF") +# set ampere meter mode +ppk2_test.use_ampere_meter() ppk2_test.start_measuring() for i in range(0, 1000): read_data = ppk2_test.get_data() - if read_data != b'': + if read_data != b"": samples, raw_digital = ppk2_test.get_samples(read_data) print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") @@ -64,6 +69,8 @@ # Print last 10 values of each channel print(ch[-10:]) print() - time.sleep(0.01) # lower time between sampling -> less samples read in one sampling period -ppk2_test.stop_measuring() + # lower time between sampling -> less samples read in one sampling period + time.sleep(0.01) + +ppk2_test.stop_measuring() \ No newline at end of file diff --git a/example_mp.py b/example_mp.py index 183d5ad..9de3c1c 100644 --- a/example_mp.py +++ b/example_mp.py @@ -1,4 +1,3 @@ - """ Basic usage of PPK2 Python API - multiprocessing version. The basic ampere mode sequence is: @@ -6,35 +5,47 @@ 2. set ampere mode 3. read stream of data """ + import time from ppk2_api.ppk2_api import PPK2_MP as PPK2_API ppk2s_connected = PPK2_API.list_devices() -if(len(ppk2s_connected) == 1): - ppk2_port = ppk2s_connected[0] - print(f'Found PPK2 at {ppk2_port}') +if len(ppk2s_connected) == 1: + ppk2_port = ppk2s_connected[0][0] + ppk2_serial = ppk2s_connected[0][1] + print(f"Found PPK2 at {ppk2_port} with serial number {ppk2_serial}") else: - print(f'Too many connected PPK2\'s: {ppk2s_connected}') + print(f"Too many connected PPK2's: {ppk2s_connected}") exit() -ppk2_test = PPK2_API(ppk2_port, buffer_max_size_seconds=1, buffer_chunk_seconds=0.01, timeout=1, write_timeout=1, exclusive=True) +ppk2_test = PPK2_API( + ppk2_port, + buffer_max_size_seconds=1, + buffer_chunk_seconds=0.01, + timeout=1, + write_timeout=1, + exclusive=True, +) ppk2_test.get_modifiers() ppk2_test.set_source_voltage(3300) """ Source mode example """ -ppk2_test.use_source_meter() # set source meter mode -ppk2_test.toggle_DUT_power("ON") # enable DUT power +# set source meter mode +ppk2_test.use_source_meter() +# enable DUT power +ppk2_test.toggle_DUT_power("ON") +# start measuring +ppk2_test.start_measuring() -ppk2_test.start_measuring() # start measuring # measurements are a constant stream of bytes # the number of measurements in one sampling period depends on the wait between serial reads # it appears the maximum number of bytes received is 1024 # the sampling rate of the PPK2 is 100 samples per millisecond while True: read_data = ppk2_test.get_data() - if read_data != b'': + if read_data != b"": samples, raw_digital = ppk2_test.get_samples(read_data) print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") @@ -49,18 +60,21 @@ time.sleep(0.001) -ppk2_test.toggle_DUT_power("OFF") # disable DUT power + +# disable DUT power +ppk2_test.toggle_DUT_power("OFF") ppk2_test.stop_measuring() """ Ampere mode example """ -ppk2_test.use_ampere_meter() # set ampere meter mode +# set ampere meter mode +ppk2_test.use_ampere_meter() ppk2_test.start_measuring() while True: read_data = ppk2_test.get_data() - if read_data != b'': + if read_data != b"": samples, raw_digital = ppk2_test.get_samples(read_data) print(f"Average of {len(samples)} samples is: {sum(samples)/len(samples)}uA") @@ -72,6 +86,8 @@ # Print last 10 values of each channel print(ch[-10:]) print() - time.sleep(0.001) # lower time between sampling -> less samples read in one sampling period -ppk2_test.stop_measuring() + # lower time between sampling -> less samples read in one sampling period + time.sleep(0.001) + +ppk2_test.stop_measuring() \ No newline at end of file diff --git a/images/baterry_sim.jpg b/images/baterry_sim.jpg new file mode 100644 index 0000000..1ac38df Binary files /dev/null and b/images/baterry_sim.jpg differ diff --git a/images/battety_simulation_mode.png b/images/battety_simulation_mode.png new file mode 100644 index 0000000..eb737b6 Binary files /dev/null and b/images/battety_simulation_mode.png differ diff --git a/images/smu_emulator.png b/images/smu_emulator.png new file mode 100644 index 0000000..c31dba3 Binary files /dev/null and b/images/smu_emulator.png differ diff --git a/setup.py b/setup.py index bab7e24..1a43e41 100644 --- a/setup.py +++ b/setup.py @@ -1,40 +1,44 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- -from __future__ import absolute_import -from __future__ import print_function +import setuptools -import io -from glob import glob -from os.path import basename -from os.path import dirname -from os.path import join -from os.path import splitext +# Use the README.md as the long description +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() -from setuptools import find_packages -from setuptools import setup +# Define the core dependencies (pyserial is essential for PPK2 via UART) +REQUIRED_PACKAGES = [ + 'pyserial>=3.4', + # Add any other core dependencies here if needed (e.g., numpy for data processing) +] +setuptools.setup( + # --- Metadata --- + name="ppk2-api", + version="0.1.0", # Start with a reasonable version number + author="RolandWa", # Replace with your name/alias + description="Unofficial Python API for Nordic Semiconductor Power Profiling Kit 2 (PPK2).", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/RolandWa/ppk2-api-python", # Replace with your correct GitHub URL + license="GPL-2.0-only", # Explicitly state the license -def read(*names, **kwargs): - with io.open( - join(dirname(__file__), *names), encoding=kwargs.get("encoding", "utf8") - ) as fh: - return fh.read() + # --- Package & Source Files --- + # Finds all packages in the 'src' directory + packages=setuptools.find_packages(where="src"), + package_dir={"": "src"}, + # --- Dependencies and Compatibility --- + install_requires=REQUIRED_PACKAGES, + python_requires='>=3.6', # Define minimum required Python version -setup( - name="ppk2-api", - version="0.9.2", - description="API for Nordic Semiconductor's Power Profiler Kit II (PPK 2).", - url="https://github.com/IRNAS/ppk2-api-python", - packages=find_packages("src"), - package_dir={"": "src"}, - py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], - install_requires=[ - "pyserial", - ], - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", - "Operating System :: OS Independent", - ], + # --- Classifiers (For PyPI) --- + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "License :: OSI Approved :: GNU General Public License v2 only (GPLv2 only)", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Electronic" + ], ) \ No newline at end of file diff --git a/src/power_profiler.py b/src/power_profiler.py index 7af0f6f..6d9b12d 100644 --- a/src/power_profiler.py +++ b/src/power_profiler.py @@ -2,19 +2,21 @@ import csv import datetime from threading import Thread + # import numpy as np # import matplotlib.pyplot as plt # import matplotlib from ppk2_api.ppk2_api import PPK2_MP as PPK2_API -class PowerProfiler(): + +class PowerProfiler: def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): """Initialize PPK2 power profiler with serial""" self.measuring = None self.measurement_thread = None self.ppk2 = None - print(f"Initing power profiler") + print("Initing power profiler") # try: if serial_port: @@ -26,7 +28,8 @@ def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): self.ppk2 = PPK2_API(serial_port) try: - ret = self.ppk2.get_modifiers() # try to read modifiers, if it fails serial port is probably not correct + # try to read modifiers, if it fails serial port is probably not correct + ret = self.ppk2.get_modifiers() print(f"Initialized ppk2 api: {ret}") except Exception as e: print(f"Error initializing power profiler: {e}") @@ -35,13 +38,16 @@ def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): if not ret: self.ppk2 = None - raise Exception(f"Error when initing PowerProfiler with serial port {serial_port}") + raise Exception( + f"Error when initing PowerProfiler with serial port {serial_port}" + ) else: self.ppk2.use_source_meter() self.source_voltage_mV = source_voltage_mV - self.ppk2.set_source_voltage(self.source_voltage_mV) # set to 3.3V + # set to 3.3V + self.ppk2.set_source_voltage(self.source_voltage_mV) print(f"Set power profiler source voltage: {self.source_voltage_mV}") @@ -62,7 +68,7 @@ def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): # write to csv self.filename = filename if self.filename is not None: - with open(self.filename, 'w', newline='') as file: + with open(self.filename, "w", newline="") as file: writer = csv.writer(file) row = [] for key in ["ts", "avg1000"]: @@ -71,10 +77,10 @@ def __init__(self, serial_port=None, source_voltage_mV=3300, filename=None): def write_csv_rows(self, samples): """Write csv row""" - with open(self.filename, 'a', newline='') as file: + with open(self.filename, "a", newline="") as file: writer = csv.writer(file) for sample in samples: - row = [datetime.datetime.now().strftime('%d-%m-%Y %H:%M:%S.%f'), sample] + row = [datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S.%f"), sample] writer.writerow(row) def delete_power_profiler(self): @@ -85,26 +91,26 @@ def delete_power_profiler(self): print("Deleting power profiler") if self.measurement_thread: - print(f"Joining measurement thread") + print("Joining measurement thread") self.measurement_thread.join() self.measurement_thread = None if self.ppk2: - print(f"Disabling ppk2 power") + print("Disabling ppk2 power") self.disable_power() del self.ppk2 - print(f"Deleted power profiler") + print("Deleted power profiler") def discover_port(self): """Discovers ppk2 serial port""" ppk2s_connected = PPK2_API.list_devices() - if(len(ppk2s_connected) == 1): + if len(ppk2s_connected) == 1: ppk2_port = ppk2s_connected[0] - print(f'Found PPK2 at {ppk2_port}') + print(f"Found PPK2 at {ppk2_port}") return ppk2_port else: - print(f'Too many connected PPK2\'s: {ppk2s_connected}') + print(f"Too many connected PPK2's: {ppk2s_connected}") return None def enable_power(self): @@ -124,16 +130,21 @@ def disable_power(self): def measurement_loop(self): """Endless measurement loop will run in a thread""" while True and not self.stop: - if self.measuring: # read data if currently measuring + # read data if currently measuring + if self.measuring: read_data = self.ppk2.get_data() - if read_data != b'': + if read_data != b"": samples = self.ppk2.get_samples(read_data) - self.current_measurements += samples # can easily sum lists, will append individual data - time.sleep(0.001) # TODO figure out correct sleep duration + # can easily sum lists, will append individual data + self.current_measurements += samples + # TODO figure out correct sleep duration + time.sleep(0.001) def _average_samples(self, list, window_size): """Average samples based on window size""" - chunks = [list[val:val + window_size] for val in range(0, len(list), window_size)] + chunks = [ + list[val : val + window_size] for val in range(0, len(list), window_size) + ] avgs = [] for chunk in chunks: avgs.append(sum(chunk) / len(chunk)) @@ -142,19 +153,24 @@ def _average_samples(self, list, window_size): def start_measuring(self): """Start measuring""" - if not self.measuring: # toggle measuring flag only if currently not measuring - self.current_measurements = [] # reset current measurements - self.measuring = True # set internal flag - self.ppk2.start_measuring() # send command to ppk2 + # toggle measuring flag only if currently not measuring + if not self.measuring: + # reset current measurements + self.current_measurements = [] + # set internal flag + self.measuring = True + # send command to ppk2 + self.ppk2.start_measuring() self.measurement_start_time = time.time() def stop_measuring(self): """Stop measuring and return average of period""" self.measurement_stop_time = time.time() self.measuring = False - self.ppk2.stop_measuring() # send command to ppk2 + # send command to ppk2 + self.ppk2.stop_measuring() - #samples_average = self._average_samples(self.current_measurements, 1000) + # samples_average = self._average_samples(self.current_measurements, 1000) if self.filename is not None: self.write_csv_rows(self.current_measurements) @@ -172,24 +188,33 @@ def get_average_current_mA(self): if len(self.current_measurements) == 0: return 0 - average_current_mA = (sum(self.current_measurements) / len(self.current_measurements)) / 1000 # measurements are in microamperes, divide by 1000 + # measurements are in microamperes, divide by 1000 + average_current_mA = ( + sum(self.current_measurements) / len(self.current_measurements) + ) / 1000 return average_current_mA def get_average_power_consumption_mWh(self): """Return average power consumption of last measurement in mWh""" average_current_mA = self.get_average_current_mA() - average_power_mW = (self.source_voltage_mV / 1000) * average_current_mA # divide by 1000 as source voltage is in millivolts - this gives us milliwatts - measurement_duration_h = self.get_measurement_duration_s() / 3600 # duration in seconds, divide by 3600 to get hours + # divide by 1000 as source voltage is in millivolts - this gives us milliwatts + average_power_mW = (self.source_voltage_mV / 1000) * average_current_mA + # duration in seconds, divide by 3600 to get hours + measurement_duration_h = self.get_measurement_duration_s() / 3600 average_consumption_mWh = average_power_mW * measurement_duration_h return average_consumption_mWh def get_average_charge_mC(self): """Returns average charge in milli coulomb""" average_current_mA = self.get_average_current_mA() - measurement_duration_s = self.get_measurement_duration_s() # in seconds + # in seconds + measurement_duration_s = self.get_measurement_duration_s() return average_current_mA * measurement_duration_s def get_measurement_duration_s(self): """Returns duration of measurement""" - measurement_duration_s = (self.measurement_stop_time - self.measurement_start_time) # measurement duration in seconds + # measurement duration in seconds + measurement_duration_s = ( + self.measurement_stop_time - self.measurement_start_time + ) return measurement_duration_s \ No newline at end of file diff --git a/src/ppk2_api/__init__.py b/src/ppk2_api/__init__.py index e69de29..afc9510 100644 --- a/src/ppk2_api/__init__.py +++ b/src/ppk2_api/__init__.py @@ -0,0 +1 @@ +from .ppk2_api import PPK2_MP \ No newline at end of file diff --git a/src/ppk2_api/ppk2_api.py b/src/ppk2_api/ppk2_api.py index 58bab9c..9c222f7 100644 --- a/src/ppk2_api/ppk2_api.py +++ b/src/ppk2_api/ppk2_api.py @@ -12,8 +12,10 @@ import queue import threading -class PPK2_Command(): + +class PPK2_Command: """Serial command opcodes""" + NO_OP = 0x00 TRIGGER_SET = 0x01 AVG_NUM_SET = 0x02 # no-firmware @@ -24,11 +26,11 @@ class PPK2_Command(): AVERAGE_STOP = 0x07 RANGE_SET = 0x08 LCD_SET = 0x09 - TRIGGER_STOP = 0x0a - DEVICE_RUNNING_SET = 0x0c - REGULATOR_SET = 0x0d - SWITCH_POINT_DOWN = 0x0e - SWITCH_POINT_UP = 0x0f + TRIGGER_STOP = 0x0A + DEVICE_RUNNING_SET = 0x0C + REGULATOR_SET = 0x0D + SWITCH_POINT_DOWN = 0x0E + SWITCH_POINT_UP = 0x0F TRIGGER_EXT_TOGGLE = 0x11 SET_POWER_MODE = 0x11 RES_USER_SET = 0x12 @@ -39,18 +41,19 @@ class PPK2_Command(): SET_USER_GAINS = 0x25 -class PPK2_Modes(): +class PPK2_Modes: """PPK2 measurement modes""" + AMPERE_MODE = "AMPERE_MODE" SOURCE_MODE = "SOURCE_MODE" -class PPK2_API(): +class PPK2_API: def __init__(self, port: str, **kwargs): - ''' + """ port - port where PPK2 is connected **kwargs - keyword arguments to pass to the pySerial constructor - ''' + """ self.ser = None self.ser = serial.Serial(port, **kwargs) @@ -66,7 +69,7 @@ def __init__(self, port: str, **kwargs): "I": {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0}, "UG": {"0": 1, "1": 1, "2": 1, "3": 1, "4": 1}, "HW": None, - "IA": None + "IA": None, } self.vdd_low = 800 @@ -93,11 +96,14 @@ def __init__(self, port: str, **kwargs): self.after_spike = 0 # adc measurement buffer remainder and len of remainder - self.remainder = {"sequence": b'', "len": 0} + self.remainder = {"sequence": b"", "len": 0} def __del__(self): """Destructor""" try: + # reset device + self._write_serial((PPK2_Command.RESET,)) + if self.ser: self.ser.close() except Exception as e: @@ -118,7 +124,8 @@ def _write_serial(self, cmd_tuple): def _twos_comp(self, val): """Compute the 2's complement of int32 value""" if (val & (1 << (32 - 1))) != 0: - val = val - (1 << 32) # compute negative value + # compute negative value + val = val - (1 << 32) return val def _convert_source_voltage(self, mV): @@ -135,11 +142,13 @@ def _convert_source_voltage(self, mV): # get difference to baseline (the baseline is 800mV but the initial offset is 32) diff_to_baseline = mV - self.vdd_low + offset base_b_1 = 3 - base_b_2 = 0 # is actually 32 - compensated with above offset + # is actually 32 - compensated with above offset + base_b_2 = 0 # get the number of times we have to increase the first byte of the command ratio = int(diff_to_baseline / 256) - remainder = diff_to_baseline % 256 # get the remainder for byte 2 + # get the remainder for byte 2 + remainder = diff_to_baseline % 256 set_b_1 = base_b_1 + ratio set_b_2 = base_b_2 + remainder @@ -155,7 +164,7 @@ def _read_metadata(self): time.sleep(0.1) # TODO add a read_until serial read function with a timeout - if read != b'' and "END" in read.decode("utf-8"): + if read != b"" and "END" in read.decode("utf-8"): return read.decode("utf-8") def _parse_metadata(self, metadata): @@ -169,23 +178,22 @@ def _parse_metadata(self, metadata): if key == data_pair[0]: self.modifiers[key] = data_pair[1] for ind in range(0, 5): - if key+str(ind) == data_pair[0]: + if key + str(ind) == data_pair[0]: if "R" in data_pair[0]: # problem on some PPK2s with wrong calibration values - this doesn't fix it if float(data_pair[1]) != 0: - self.modifiers[key][str(ind)] = float( - data_pair[1]) + self.modifiers[key][str(ind)] = float(data_pair[1]) else: - self.modifiers[key][str(ind)] = float( - data_pair[1]) + self.modifiers[key][str(ind)] = float(data_pair[1]) return True except Exception as e: # if exception triggers serial port is probably not correct + print(f"Failde to parse metadata: {e}") return None def _generate_mask(self, bits, pos): pos = pos - mask = ((2**bits-1) << pos) + mask = (2**bits - 1) << pos mask = self._twos_comp(mask) return {"mask": mask, "pos": pos} @@ -196,25 +204,38 @@ def _get_masked_value(self, value, meas, is_bits=False): def _handle_raw_data(self, adc_value): """Convert raw value to analog value""" try: - current_measurement_range = min(self._get_masked_value( - adc_value, self.MEAS_RANGE), 4) # 5 is the number of parameters + # 5 is the number of parameters + current_measurement_range = min( + self._get_masked_value(adc_value, self.MEAS_RANGE), 4 + ) adc_result = self._get_masked_value(adc_value, self.MEAS_ADC) * 4 bits = self._get_masked_value(adc_value, self.MEAS_LOGIC) - analog_value = self.get_adc_result( - current_measurement_range, adc_result) * 10**6 + analog_value = ( + self.get_adc_result(current_measurement_range, adc_result) * 10**6 + ) return analog_value, bits except Exception as e: - print("Measurement outside of range!") + print(f"Measurement outside of range! {e}") return None, None @staticmethod def list_devices(): import serial.tools.list_ports + ports = serial.tools.list_ports.comports() - if os.name == 'nt': - devices = [port.device for port in ports if port.description.startswith("nRF Connect USB CDC ACM")] + if os.name == "nt": + devices = [ + (port.device, port.serial_number[:8]) + for port in ports + if port.description.startswith("nRF Connect USB CDC ACM") + and port.location.endswith("1") + ] else: - devices = [port.device for port in ports if port.product == 'PPK2'] + devices = [ + (port.device, port.serial_number[:8]) + for port in ports + if port.product == "PPK2" and port.location.endswith("1") + ] return devices def get_data(self): @@ -224,7 +245,7 @@ def get_data(self): def get_modifiers(self): """Gets and sets modifiers from device memory""" - self._write_serial((PPK2_Command.GET_META_DATA, )) + self._write_serial((PPK2_Command.GET_META_DATA,)) metadata = self._read_metadata() ret = self._parse_metadata(metadata) return ret @@ -237,14 +258,14 @@ def start_measuring(self): if self.mode == PPK2_Modes.AMPERE_MODE: raise Exception("Input voltage not set!") - self._write_serial((PPK2_Command.AVERAGE_START, )) + self._write_serial((PPK2_Command.AVERAGE_START,)) def stop_measuring(self): """Stop continuous measurement""" - self._write_serial((PPK2_Command.AVERAGE_STOP, )) + self._write_serial((PPK2_Command.AVERAGE_STOP,)) def set_source_voltage(self, mV): - """Inits device - based on observation only REGULATOR_SET is the command. + """Inits device - based on observation only REGULATOR_SET is the command. The other two values correspond to the voltage level. 800mV is the lowest setting - [3,32] - the values then increase linearly @@ -256,32 +277,44 @@ def set_source_voltage(self, mV): def toggle_DUT_power(self, state): """Toggle DUT power based on parameter""" if state == "ON": + # 12,1 self._write_serial( - (PPK2_Command.DEVICE_RUNNING_SET, PPK2_Command.TRIGGER_SET)) # 12,1 + (PPK2_Command.DEVICE_RUNNING_SET, PPK2_Command.TRIGGER_SET) + ) if state == "OFF": - self._write_serial( - (PPK2_Command.DEVICE_RUNNING_SET, PPK2_Command.NO_OP)) # 12,0 + # 12,0 + self._write_serial((PPK2_Command.DEVICE_RUNNING_SET, PPK2_Command.NO_OP)) def use_ampere_meter(self): """Configure device to use ampere meter""" self.mode = PPK2_Modes.AMPERE_MODE - self._write_serial((PPK2_Command.SET_POWER_MODE, - PPK2_Command.TRIGGER_SET)) # 17,1 + # 17,1 + self._write_serial((PPK2_Command.SET_POWER_MODE, PPK2_Command.TRIGGER_SET)) def use_source_meter(self): """Configure device to use source meter""" self.mode = PPK2_Modes.SOURCE_MODE - self._write_serial((PPK2_Command.SET_POWER_MODE, - PPK2_Command.AVG_NUM_SET)) # 17,2 + # 17,2 + self._write_serial((PPK2_Command.SET_POWER_MODE, PPK2_Command.AVG_NUM_SET)) def get_adc_result(self, current_range, adc_value): """Get result of adc conversion""" current_range = str(current_range) result_without_gain = (adc_value - self.modifiers["O"][current_range]) * ( - self.adc_mult / self.modifiers["R"][current_range]) - adc = self.modifiers["UG"][current_range] * (result_without_gain * (self.modifiers["GS"][current_range] * result_without_gain + self.modifiers["GI"][current_range]) + ( - self.modifiers["S"][current_range] * (self.current_vdd / 1000) + self.modifiers["I"][current_range])) + self.adc_mult / self.modifiers["R"][current_range] + ) + adc = self.modifiers["UG"][current_range] * ( + result_without_gain + * ( + self.modifiers["GS"][current_range] * result_without_gain + + self.modifiers["GI"][current_range] + ) + + ( + self.modifiers["S"][current_range] * (self.current_vdd / 1000) + + self.modifiers["I"][current_range] + ) + ) prev_rolling_avg = self.rolling_avg prev_rolling_avg4 = self.rolling_avg4 @@ -290,12 +323,18 @@ def get_adc_result(self, current_range, adc_value): if self.rolling_avg is None: self.rolling_avg = adc else: - self.rolling_avg = self.spike_filter_alpha * adc + (1 - self.spike_filter_alpha) * self.rolling_avg - + self.rolling_avg = ( + self.spike_filter_alpha * adc + + (1 - self.spike_filter_alpha) * self.rolling_avg + ) + if self.rolling_avg4 is None: self.rolling_avg4 = adc else: - self.rolling_avg4 = self.spike_filter_alpha5 * adc + (1 - self.spike_filter_alpha5) * self.rolling_avg4 + self.rolling_avg4 = ( + self.spike_filter_alpha5 * adc + + (1 - self.spike_filter_alpha5) * self.rolling_avg4 + ) if self.prev_range is None: self.prev_range = current_range @@ -314,7 +353,7 @@ def get_adc_result(self, current_range, adc_value): adc = self.rolling_avg4 else: adc = self.rolling_avg - + self.after_spike -= 1 self.prev_range = current_range @@ -322,7 +361,8 @@ def get_adc_result(self, current_range, adc_value): def _digital_to_analog(self, adc_value): """Convert discrete value to analog value""" - return int.from_bytes(adc_value, byteorder="little", signed=False) # convert reading to analog value + # convert reading to analog value + return int.from_bytes(adc_value, byteorder="little", signed=False) def digital_channels(self, bits): """ @@ -351,14 +391,13 @@ def get_samples(self, buf): Manipulation of samples is left to the user. See example for more info. """ - - sample_size = 4 # one analog value is 4 bytes in size + # one analog value is 4 bytes in size + sample_size = 4 offset = self.remainder["len"] samples = [] raw_digital_output = [] - first_reading = ( - self.remainder["sequence"] + buf[0:sample_size-offset])[:4] + first_reading = (self.remainder["sequence"] + buf[0 : sample_size - offset])[:4] adc_val = self._digital_to_analog(first_reading) measurement, bits = self._handle_raw_data(adc_val) if measurement is not None: @@ -369,7 +408,7 @@ def get_samples(self, buf): offset = sample_size - offset while offset <= len(buf) - sample_size: - next_val = buf[offset:offset + sample_size] + next_val = buf[offset : offset + sample_size] offset += sample_size adc_val = self._digital_to_analog(next_val) measurement, bits = self._handle_raw_data(adc_val) @@ -378,18 +417,19 @@ def get_samples(self, buf): if bits is not None: raw_digital_output.append(bits) - self.remainder["sequence"] = buf[offset:len(buf)] - self.remainder["len"] = len(buf)-offset + self.remainder["sequence"] = buf[offset : len(buf)] + self.remainder["len"] = len(buf) - offset # return list of samples and raw digital outputs # handle those lists in PPK2 API wrapper - return samples, raw_digital_output + return samples, raw_digital_output class PPK_Fetch(threading.Thread): - ''' + """ Background process for polling the data in multi-threaded variant - ''' + """ + def __init__(self, ppk2, quit_evt, buffer_len_s=10, buffer_chunk_s=0.5): super().__init__() self._ppk2 = ppk2 @@ -399,8 +439,10 @@ def __init__(self, ppk2, quit_evt, buffer_len_s=10, buffer_chunk_s=0.5): self._stats = (None, None) self._last_timestamp = 0 - self._buffer_max_len = int(buffer_len_s * 100000 * 4) # 100k 4-byte samples per second - self._buffer_chunk = int(buffer_chunk_s * 100000 * 4) # put in the queue in chunks of 0.5s + # 100k 4-byte samples per second + self._buffer_max_len = int(buffer_len_s * 100000 * 4) + # put in the queue in chunks of 0.5s + self._buffer_chunk = int(buffer_chunk_s * 100000 * 4) # round buffers to a whole sample if self._buffer_max_len % 4 != 0: @@ -413,17 +455,19 @@ def __init__(self, ppk2, quit_evt, buffer_len_s=10, buffer_chunk_s=0.5): def run(self): s = 0 t = time.time() - local_buffer = b'' + local_buffer = b"" while not self._quit.is_set(): d = PPK2_API.get_data(self._ppk2) tm_now = time.time() local_buffer += d while len(local_buffer) >= self._buffer_chunk: # FIXME: check if lock might be needed when discarding old data - self._buffer_q.put(local_buffer[:self._buffer_chunk]) - while self._buffer_q.qsize()>self._buffer_max_len/self._buffer_chunk: + self._buffer_q.put(local_buffer[: self._buffer_chunk]) + while ( + self._buffer_q.qsize() > self._buffer_max_len / self._buffer_chunk + ): self._buffer_q.get() - local_buffer = local_buffer[self._buffer_chunk:] + local_buffer = local_buffer[self._buffer_chunk :] self._last_timestamp = tm_now # calculate stats @@ -446,11 +490,12 @@ def run(self): break def get_data(self): - ret = b'' + ret = b"" count = 0 while True: try: - ret += self._buffer_q.get(timeout=0.001) # get_nowait sometimes skips a chunk for some reason + # get_nowait sometimes skips a chunk for some reason + ret += self._buffer_q.get(timeout=0.001) count += 1 except queue.Empty: break @@ -458,17 +503,20 @@ def get_data(self): class PPK2_MP(PPK2_API): - ''' + """ Multiprocessing variant of the object. The interface is the same as for the regular one except it spawns a background process on start_measuring() - ''' - def __init__(self, port, buffer_max_size_seconds=10, buffer_chunk_seconds=0.1, **kwargs): - ''' + """ + + def __init__( + self, port, buffer_max_size_seconds=10, buffer_chunk_seconds=0.1, **kwargs + ): + """ port - port where PPK2 is connected buffer_max_size_seconds - how many seconds of data to keep in the buffer buffer_chunk_seconds - how many seconds of data to put in the queue at once **kwargs - keyword arguments to pass to the pySerial constructor - ''' + """ super().__init__(port, **kwargs) self._fetcher = None @@ -490,27 +538,32 @@ def __del__(self): def start_measuring(self): # discard the data in the buffer self.stop_measuring() - while self.get_data()!=b'': + while self.get_data() != b"": pass PPK2_API.start_measuring(self) self._quit_evt.clear() if self._fetcher is not None: return - - self._fetcher = PPK_Fetch(self, self._quit_evt, self._buffer_max_size_seconds, self._buffer_chunk_seconds) + + self._fetcher = PPK_Fetch( + self, + self._quit_evt, + self._buffer_max_size_seconds, + self._buffer_chunk_seconds, + ) self._fetcher.start() def stop_measuring(self): PPK2_API.stop_measuring(self) - self.get_data() # flush the serial buffer (to prevent unicode error on next command) + self.get_data() # flush the serial buffer (to prevent unicode error on next command) self._quit_evt.set() if self._fetcher is not None: - self._fetcher.join() # join() will block if the queue isn't empty + self._fetcher.join() # join() will block if the queue isn't empty self._fetcher = None def get_data(self): try: return self._fetcher.get_data() except (TypeError, AttributeError): - return b'' + return b"" \ No newline at end of file diff --git a/tools/ppk2_battery_emulator_gui.py b/tools/ppk2_battery_emulator_gui.py new file mode 100644 index 0000000..7ebaccf --- /dev/null +++ b/tools/ppk2_battery_emulator_gui.py @@ -0,0 +1,1046 @@ +import tkinter as tk +from tkinter import filedialog, ttk, messagebox +import threading +import time +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import numpy as np +import random +import re +from datetime import datetime + +# ---------------------------------------------------------------------- +# PPK2 API INTEGRATION (Adapter Classes) +# ---------------------------------------------------------------------- + +# --- API LOADING LOGIC --- +try: + # Attempt to import the real API components + from ppk2_api.ppk2_api import PPK2_API, PPK2_Command, PPK2_Modes + + API_AVAILABLE = True + API_MODE = "REAL" + print("INFO: PPK2 API found. Attempting REAL mode.") +except ImportError: + API_AVAILABLE = False + API_MODE = "MOCK" + print("WARNING: PPK2 API not found. Using MOCK mode for simulation.") + + +class PPK2Adapter: + """Base class for the PPK2 interface to ensure common methods.""" + + def connect(self): + raise NotImplementedError + + def start_measurement(self, voltage_v, samplerate, logic_enabled, spike_filtering_enabled): + raise NotImplementedError + + def get_measurements(self): + """Returns tuple (currents_ma, all_logic_samples) where all_logic_samples is a list of [D0..D7] states per current sample.""" + raise NotImplementedError + + def set_voltage(self, voltage_v): + raise NotImplementedError + + def stop(self): + raise NotImplementedError + + +# --- MOCK DEFINITIONS (Always available for fallback) --- +BASE_I_QUIET = 50.0 # Static base for quiet current (mA) +BASE_I_PEAK = 2000.0 # Static base for peak current (mA) +NOISE_RANGE = 2.5 # Random fluctuation range (±0.5 mA) + + +class PPK2Mock(PPK2Adapter): + """Emulates the PPK2_API interface for GUI testing.""" + + def connect(self): + print("MOCK: Connect successful.") + return True # Simulate successful connection + + def start_measurement(self, voltage_v, samplerate, logic_enabled, spike_filtering_enabled): + self.samplerate = samplerate + self.logic_enabled = logic_enabled + self.voltage = voltage_v + self.spike_filtering_enabled = spike_filtering_enabled + self.start_time = time.time() + print( + f"MOCK: Started Source Meter @ {samplerate} Hz (Logic: {logic_enabled}, Spike Filter: {spike_filtering_enabled})") + + def set_voltage(self, voltage_v): + self.voltage = voltage_v + + def get_measurements(self): + """Simulate dynamic current draw and logic data.""" + t = time.time() + + # Consistent with real measurement rate (100kS/s * 0.01s loop step = 1000 samples) + num_samples = 1000 + currents_ma = [] + all_logic_samples = [] + + for i in range(num_samples): + # Simulate bursts of current every 5 seconds + current_time = t + i / self.samplerate + if (current_time) % 5 < 0.2: + I_base = BASE_I_PEAK + else: + I_base = BASE_I_QUIET + + I_noise = random.uniform(-NOISE_RANGE, NOISE_RANGE) + currents_ma.append(I_base + I_noise) + + # Simulate logic data for one sample + if self.logic_enabled: + # D0 toggles every 1s, D1 toggles every 0.5s, D5 toggles every 10s + all_logic_samples.append([ + (current_time % 2) > 1, + (current_time % 0.5) > 0.25, + random.choice([True, False]), + False, + True, + (current_time % 10) > 9.5, + False, + False + ]) + else: + all_logic_samples.append([False] * 8) + + # Apply simple spike filter simulation (if enabled, smooth peaks slightly) + if self.spike_filtering_enabled: + currents_ma = np.convolve(currents_ma, np.ones(5) / 5, mode='same').tolist() + + return currents_ma, all_logic_samples + + def stop(self): + print("MOCK: Stopped.") + + +# --- REAL PPK2 ADAPTER --- +if API_AVAILABLE: + class PPK2Real(PPK2Adapter): + """Adapts the ppk2_api.PPK2_API to the common interface.""" + + def __init__(self, port): + self.logic_enabled = False + # Initialize the real API object + self.ppk2 = PPK2_API(port, timeout=1, write_timeout=1, exclusive=True) + self.ppk2.get_modifiers() # Always do this first + + def connect(self): + return True # Connection is handled in __init__ + + def _set_spike_filtering(self, state): + """Internal method to set spike filtering using raw commands.""" + # Uses the available serial write and the command constants provided by the API. + if state: + # PPK2_Command.SPIKE_FILTERING_ON = 0x15 + self.ppk2._write_serial((PPK2_Command.SPIKE_FILTERING_ON,)) + print("REAL: Spike Filtering ON") + else: + # PPK2_Command.SPIKE_FILTERING_OFF = 0x16 + self.ppk2._write_serial((PPK2_Command.SPIKE_FILTERING_OFF,)) + print("REAL: Spike Filtering OFF") + + def start_measurement(self, voltage_v, samplerate, logic_enabled, spike_filtering_enabled): + # Convert V to mV for the API + voltage_mv = int(voltage_v * 1000) + self.logic_enabled = logic_enabled + + self.set_voltage(voltage_mv) + + # Set mode and power + self.ppk2.use_source_meter() + self.ppk2.toggle_DUT_power("ON") + + # Set spike filtering + self._set_spike_filtering(spike_filtering_enabled) + + # Start continuous measurement + self.ppk2.start_measuring() + + def set_voltage(self, voltage_v): + voltage_mv = int(voltage_v * 1000) + self.ppk2.set_source_voltage(voltage_mv) + + def get_measurements(self): + """Returns tuple (currents_ma, all_logic_samples)""" + raw_data = self.ppk2.get_data() + if raw_data: + # Get current samples (in uA) and raw digital data (list of bytes/ints) + samples_uA, raw_digital_data = self.ppk2.get_samples(raw_data) + + # Convert current from uA to mA + currents_ma = [i / 1000.0 for i in samples_uA] + + all_logic_samples = [] + if self.logic_enabled: + # Process the raw digital data (each byte is D0-D7 state for one sample) + for raw_byte in raw_digital_data: + # Convert byte value to 8-bit list [D0, D1, ..., D7] + all_logic_samples.append([bool((raw_byte >> i) & 1) for i in range(8)]) + else: + # Provide empty data structure for consistency + all_logic_samples = [[False] * 8 for _ in range(len(currents_ma))] + + return currents_ma, all_logic_samples + + # Return empty data if no data was read + return [], [] + + def stop(self): + self.ppk2.stop_measuring() + self.ppk2.toggle_DUT_power("OFF") + # Clean up the serial connection + del self.ppk2 + self.ppk2 = None + print("REAL: Stopped.") + + +def init_ppk2_device(voltage_v, samplerate_hz, logic_enabled=False, spike_filtering_enabled=False): + """Initializes the PPK2 device (Real or Mock).""" + global API_MODE + + if API_MODE == "REAL": + ppk2s_connected = PPK2_API.list_devices() + if len(ppk2s_connected) == 1: + ppk2_port = ppk2s_connected[0][0] + try: + device = PPK2Real(ppk2_port) + device.start_measurement( + voltage_v, + samplerate=samplerate_hz, + logic_enabled=logic_enabled, + spike_filtering_enabled=spike_filtering_enabled + ) + return device + except Exception as e: + # Fallback to mock on real error + print(f"Error initializing real PPK2: {e}. Falling back to MOCK.") + API_MODE = "MOCK" + else: + if len(ppk2s_connected) > 1: + print(f"ERROR: Found too many connected PPK2's: {ppk2s_connected}. Please connect only one.") + else: + print("ERROR: No PPK2 device found.") + print("Falling back to MOCK.") + API_MODE = "MOCK" + + # Fallback/Default to MOCK + mock_device = PPK2Mock() + mock_device.start_measurement( + voltage_v, + samplerate=samplerate_hz, + logic_enabled=logic_enabled, + spike_filtering_enabled=spike_filtering_enabled + ) + return mock_device + + +# ---------------------------------------------------------------------- +# EMULATION CONSTANTS AND BATTERY PARAMETERS +# ---------------------------------------------------------------------- + +DISCHARGE_RATE = 100 # Emulation acceleration factor (100x faster than real time) +TIME_STEP_REAL_SEC = 0.5 # The amount of real time simulated in one emulation step +TIME_STEP_EMUL_SEC = TIME_STEP_REAL_SEC / DISCHARGE_RATE # The actual time the PPK2 samples current + + +class BatteryEmulator(tk.Frame): + # Static data for battery technologies and their discharge limits + BATTERY_PROFILES = { + "Li-Po/Li-Ion (3.7V)": {"V_START": 4.2, "V_STOP": 3.2}, + "LiFePO4 (3.2V)": {"V_START": 3.6, "V_STOP": 2.8}, + "NiMH/NiCd (1.2V)": {"V_START": 1.4, "V_STOP": 1.0}, + "Alkaline (1.5V)": {"V_START": 1.6, "V_STOP": 1.2} + } + + def __init__(self, master=None): + super().__init__(master) + self.master = master + # Update title based on determined API mode + self.master.title(f"PPK2 [{API_MODE}] - Battery Simulator and Current Profiler") + self.ppk2 = None + self.is_running = False + self.data_thread = None + + # State Variables + self.current_voltage = 0.0 + self.current_capacity_mah = 0.0 + self.time_elapsed_real_sec = 0 + self.all_current_samples = [] + self.all_time_real_s = [] + self.all_voltage_v = [] + self.all_logic_data = [] + + # Configuration + self.config = { + "V_STOP": self.BATTERY_PROFILES["Li-Po/Li-Ion (3.7V)"]["V_STOP"], + "SAMPLE_RATE_HZ": 1000 + } + + self.CAPACITY_OPTIONS = [1500, 3500, 5000, 8000] + self.V_START_DEFAULT = self.BATTERY_PROFILES["Li-Po/Li-Ion (3.7V)"]["V_START"] + self.CAPACITY_DEFAULT = 3500 + self.SIM_TIME_DEFAULT_STR = "01:00" + + # GUI Variables + self.battery_type_var = tk.StringVar(value="Li-Po/Li-Ion (3.7V)") + self.v_start_var = tk.DoubleVar(value=self.V_START_DEFAULT) + self.v_stop_var = tk.DoubleVar(value=self.config["V_STOP"]) + self.capacity_var = tk.StringVar(value=str(self.CAPACITY_DEFAULT)) + self.logic_enabled_var = tk.BooleanVar(value=False) + self.spike_filtering_var = tk.BooleanVar(value=False) # <--- NEW VAR + self.sim_discharge_var = tk.BooleanVar(value=False) + self.sim_time_str_var = tk.StringVar(value=self.SIM_TIME_DEFAULT_STR) + self.linear_discharge_var = tk.BooleanVar(value=False) + + # Bind voltage variable changes to update the config + self.v_start_var.trace_add("write", self.update_config_voltage) + self.v_stop_var.trace_add("write", self.update_config_voltage) + + # Simulation specific variables + self.total_sim_time_sec = 0 + self.required_mah_loss_per_sec = 0 + + self.create_widgets() + self.load_initial_state() + self.print_battery_info() + + def print_battery_info(self): + print("\n--- Common Battery Technology Information ---") + for tech, limits in self.BATTERY_PROFILES.items(): + print( + f"| {tech:<20} | V_Start: {limits['V_START']:.1f}V | V_Stop: {limits['V_STOP']:.1f}V | Capacity: 100 mAh to 50,000 mAh+") + print("-------------------------------------------\n") + + def convert_mm_ss_to_sec(self, mm_ss_str): + """Converts MM:SS string to total seconds.""" + match = re.match(r"(\d+):(\d+)", mm_ss_str.strip()) + if match: + minutes = int(match.group(1)) + seconds = int(match.group(2)) + return minutes * 60 + seconds + try: + return float(mm_ss_str) * 60 + except: + return 0 + + def format_seconds_to_hms(self, total_seconds): + """Converts total seconds into HH:MM:SS format.""" + if total_seconds == float('inf'): + return "INF" + + total_seconds = int(total_seconds) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + return f"{hours:02}:{minutes:02}:{seconds:02}" + + def update_config_voltage(self, *args): + """Updates the internal config from the GUI DoubleVars.""" + try: + self.config["V_START"] = self.v_start_var.get() + self.config["V_STOP"] = self.v_stop_var.get() + except tk.TclError: + # Handle case where conversion fails (e.g., user is typing non-float data) + pass + + def update_voltage_from_type(self, *args): + """Updates V_START and V_STOP based on the selected battery type.""" + selected_type = self.battery_type_var.get() + profile = self.BATTERY_PROFILES.get(selected_type) + + if profile: + self.v_start_var.set(profile["V_START"]) + self.v_stop_var.set(profile["V_STOP"]) + print(f"INFO: Set V_START={profile['V_START']}V and V_STOP={profile['V_STOP']}V for {selected_type}.") + else: + print(f"WARNING: Unknown battery type selected: {selected_type}") + + def create_voltage_spinbox(self, parent, variable, row, column, label_text): + """Creates a label and an editable Spinbox with 0.01 increment/decrement.""" + ttk.Label(parent, text=label_text).grid(row=row, column=column, padx=5, pady=2, sticky='w') + + spinbox = tk.Spinbox(parent, + from_=0.0, to_=5.0, + increment=0.01, + textvariable=variable, + format="%.2f", + width=6, + command=self.update_config_voltage) # Update on button press + + spinbox.grid(row=row, column=column + 1, padx=5, pady=2, sticky='w') + + # Bind the Enter key to update the config (for manual entry) + spinbox.bind('', lambda event: self.update_config_voltage()) + + return spinbox + + def load_initial_state(self): + # Apply current settings to configuration variables + self.config["V_STOP"] = self.v_stop_var.get() + self.config["V_START"] = self.v_start_var.get() + + try: + capacity = float(self.capacity_var.get()) + except ValueError: + capacity = self.CAPACITY_DEFAULT + self.capacity_var.set(str(self.CAPACITY_DEFAULT)) + + self.config["CAPACITY_NOMINAL_MAH"] = capacity + + self.current_capacity_mah = self.config["CAPACITY_NOMINAL_MAH"] + self.current_voltage = self.config["V_START"] + self.v_label.config(text=f"V_SYS Voltage (V): {self.current_voltage:.3f}") + self.soc_label.config(text="SoC (%): 100.0 (Avg I: 0.0 mA)") + + # Prepare for Simulated Discharge Mode + if self.sim_discharge_var.get(): + self.total_sim_time_sec = self.convert_mm_ss_to_sec(self.sim_time_str_var.get()) + + # --- Calculation for Sim Discharge Mode (Linear SoC loss in time) --- + # In time simulation mode, we calculate the required mAh drop from V_START's SoC to V_STOP's SoC. + # We use the S-curve logic here (by passing V_START/V_STOP to get_soc_percent_from_voltage + # while it is running in its *internal* S-curve mode by default, UNLESS we force linear V-SoC in the function). + # Here, we use a simple 100% to 0% drop to ensure the entire capacity is used for linear time discharge. + soc_start = 100.0 # Start at 100% + soc_stop = 0.0 # Finish at 0% + + required_soc_drop = soc_start - soc_stop # 100% + required_mah_drop = required_soc_drop * (self.config["CAPACITY_NOMINAL_MAH"] / 100.0) + + if self.total_sim_time_sec > 0: + # This is the linear rate of capacity loss per real second of emulation + self.required_mah_loss_per_sec = required_mah_drop / self.total_sim_time_sec + else: + self.required_mah_loss_per_sec = 0 + + # Set initial capacity to nominal + self.current_capacity_mah = self.config["CAPACITY_NOMINAL_MAH"] + + def create_widgets(self): + # --- TOP CONTROL FRAME --- + control_frame = ttk.LabelFrame(self.master, text="Control and Configuration") + control_frame.pack(padx=10, pady=5, fill="x") + + # Row 0: Battery Type Selection and Capacity + ttk.Label(control_frame, text="Battery Type:").grid(row=0, column=0, padx=5, pady=2, sticky='w') + battery_menu = ttk.Combobox(control_frame, + textvariable=self.battery_type_var, + values=list(self.BATTERY_PROFILES.keys()), + width=20, + state="readonly") + battery_menu.grid(row=0, column=1, padx=5, pady=2, sticky='w', columnspan=2) + self.battery_type_var.trace_add("write", self.update_voltage_from_type) + + ttk.Label(control_frame, text="Capacity (mAh):").grid(row=0, column=3, padx=5, pady=2, sticky='w') + capacity_menu = ttk.Combobox(control_frame, textvariable=self.capacity_var, values=self.CAPACITY_OPTIONS, + width=8) + capacity_menu.grid(row=0, column=4, padx=5, pady=2, sticky='w') + + # Row 1: Voltage Settings (Using new Spinbox helper) + self.create_voltage_spinbox(control_frame, self.v_start_var, 1, 0, "Start V (V):") + self.create_voltage_spinbox(control_frame, self.v_stop_var, 1, 2, "Stop V (V):") + + # Row 2: Logic Analyzer, Spike Filtering and Start Button + # Logic Analyzer + ttk.Checkbutton(control_frame, text="Enable Logic Analyzer", variable=self.logic_enabled_var).grid(row=2, + column=0, + padx=5, + pady=2, + sticky='w') + + # New: Spike Filtering Checkbox + ttk.Checkbutton(control_frame, text="Enable Spike Filtering", variable=self.spike_filtering_var).grid(row=2, + column=1, + padx=5, + pady=2, + sticky='w', + columnspan=2) + + self.start_button = ttk.Button(control_frame, text="START Emulation", command=self.toggle_emulation) + self.start_button.grid(row=2, column=4, padx=10, pady=5) + + # Row 3: Simulated Discharge Controls + sim_frame = ttk.LabelFrame(control_frame, text="Simulated Time Discharge") + sim_frame.grid(row=3, column=0, columnspan=5, padx=5, pady=5, sticky='we') + + ttk.Checkbutton(sim_frame, text="Enable Time Simulation", variable=self.sim_discharge_var).pack(side='left', + padx=5, pady=2) + ttk.Label(sim_frame, text="Target Time (MM:SS):").pack(side='left', padx=5, pady=2) + ttk.Entry(sim_frame, textvariable=self.sim_time_str_var, width=8).pack(side='left', padx=5, pady=2) + ttk.Label(sim_frame, text=f"({self.SIM_TIME_DEFAULT_STR} default)").pack(side='left', padx=5, pady=2) + + ttk.Checkbutton(sim_frame, text="Test Mode: Linear V-SoC Curve", variable=self.linear_discharge_var, style='TCheckbutton').pack(side='left', padx=15, pady=2) + + # --- STATE DISPLAY FRAME --- + state_frame = ttk.LabelFrame(self.master, text="Simulation State") + state_frame.pack(padx=10, pady=5, fill="x") + self.v_label = ttk.Label(state_frame, text="V_SYS Voltage (V): 0.000", font=('Arial', 12, 'bold')) + self.v_label.grid(row=0, column=0, padx=5, pady=5) + self.soc_label = ttk.Label(state_frame, text="SoC (%): 0.0 (Avg I: 0.0 mA)", font=('Arial', 12, 'bold')) + self.soc_label.grid(row=0, column=1, padx=5, pady=5) + self.runtime_label = ttk.Label(state_frame, text="Real Runtime (h): 0.00") + self.runtime_label.grid(row=0, column=2, padx=5, pady=5) + + # Logic Analyzer Status + logic_status_frame = ttk.Frame(state_frame) + logic_status_frame.grid(row=1, column=0, columnspan=3, pady=5) + ttk.Label(logic_status_frame, text="Logic Pins:").pack(side='left', padx=5) + self.logic_indicators = [] + for i in range(8): + label = ttk.Label(logic_status_frame, text=f"D{i}: LOW", width=9, anchor='w') + label.pack(side='left', padx=2) + self.logic_indicators.append(label) + + # --- PLOT FRAME --- + plot_frame = ttk.LabelFrame(self.master, text="Current and Voltage Profile") + plot_frame.pack(padx=10, pady=5, fill="both", expand=True) + + self.fig, (self.ax_i, self.ax_v) = plt.subplots(2, 1, figsize=(8, 6), sharex=True) + + # Current Plot (Top) + self.ax_i.set_ylabel("Current (mA)") + self.ax_i.grid(True) + self.line_i, = self.ax_i.plot([], [], 'r-') + + # Voltage Plot (Bottom) + self.ax_v.set_xlabel("Relative Time (s)") + self.ax_v.set_ylabel("Voltage (V)") + self.ax_v.grid(True) + self.line_v, = self.ax_v.plot([], [], 'b-') + + self.canvas = FigureCanvasTkAgg(self.fig, master=plot_frame) + self.canvas_widget = self.canvas.get_tk_widget() + self.canvas_widget.pack(fill="both", expand=True) + + # --- STATISTICS FRAME --- + stats_frame = ttk.LabelFrame(self.master, text="Measurement Statistics") + stats_frame.pack(padx=10, pady=5, fill="x") + self.peak_label = ttk.Label(stats_frame, text="Peak Current (mA): 0.0") + self.peak_label.grid(row=0, column=0, padx=5, pady=5) + self.rms_label = ttk.Label(stats_frame, text="RMS Current (mA): 0.0") + self.rms_label.grid(row=0, column=1, padx=5, pady=5) + self.save_button = ttk.Button(stats_frame, text="Save Log (.csv)", command=self.save_data) + self.save_button.grid(row=0, column=2, padx=10, pady=5) + + def toggle_emulation(self): + if not self.is_running: + self.start_emulation() + else: + self.stop_emulation() + + def start_emulation(self): + global API_MODE + try: + self.load_initial_state() + + # 1. Initialize PPK2 using the unified function + self.ppk2 = init_ppk2_device( + self.current_voltage, # Use the voltage set in load_initial_state + self.config["SAMPLE_RATE_HZ"], + self.logic_enabled_var.get(), + self.spike_filtering_var.get() # <--- PASS NEW ARGUMENT + ) + + # Update the title in case the mode changed to MOCK + mode = "TIME SIMULATION" if self.sim_discharge_var.get() else "CURRENT EMULATION" + curve_mode = "Linear (Test)" if self.linear_discharge_var.get() else "S-Curve (Real)" + self.master.title(f"PPK2 [{API_MODE}] - Battery Simulator ({mode} / {curve_mode})") + + # 2. Reset State and Logs + self.all_current_samples = [] + self.all_time_real_s = [] + self.all_voltage_v = [] + self.all_logic_data = [] + self.time_elapsed_real_sec = 0 + + # 3. Start Data Thread + self.is_running = True + self.start_button.config(text="STOP Emulation", state=tk.NORMAL) + self.data_thread = threading.Thread(target=self.emulation_loop) + self.data_thread.daemon = True + self.data_thread.start() + + # Initial console debug message + curve_mode = "Linear Slope (Test Mode)" if self.linear_discharge_var.get() else "S-Curve (Real Mode)" + print("\n--- EMULATION STARTING ---") + print( + f"Mode: {'Time Simulation' if self.sim_discharge_var.get() else 'Current Emulation'} | Curve: {curve_mode} | Battery: {self.battery_type_var.get()}") + print( + f"Capacity: {self.config['CAPACITY_NOMINAL_MAH']} mAh | V_Start: {self.config['V_START']} V | V_Stop: {self.config['V_STOP']} V") + if self.sim_discharge_var.get(): + print( + f"Target Discharge Time: {self.sim_time_str_var.get()} (MM:SS) | Required Loss Rate: {self.required_mah_loss_per_sec * 3600.0:.2f} mA / sec (Theoretical)") + print("--------------------------") + + except Exception as e: + messagebox.showerror("PPK2 Error", f"Failed to initialize PPK2: {e}. Check API connection.") + self.stop_emulation(is_error=True) + + def stop_emulation(self, is_error=False): + if not self.is_running and not is_error: + return + + self.is_running = False + + if self.ppk2: + try: + # We do not need the real stop() for mock, but keep it structured + if API_MODE == "REAL": + self.ppk2.stop() # Uncomment for real API + pass + else: + self.ppk2.stop() # Mock stop + except Exception as e: + print(f"Error while stopping PPK2 device: {e}") + self.ppk2 = None + + if self.data_thread and self.data_thread.is_alive(): + self.data_thread.join(timeout=0.1) + + self.start_button.config(text="START Emulation", state=tk.NORMAL) + self.display_final_results() + + # --- Voltage/SoC Mapping Functions (Retained from original script) --- + def get_voltage_from_soc(self, soc_percent): + """Calculates voltage from SoC percentage based on simplified discharge curve or linear slope.""" + V_start = self.config["V_START"] + V_stop = self.config["V_STOP"] + + # Failsafe + if V_start <= V_stop: return V_start + if soc_percent >= 100: return V_start + if soc_percent <= 0: return V_stop + + V_range = V_start - V_stop + + # --- TEST MODE: Linear Slope (Linear Ramp) --- + # activated by setting 'Simulated Time Discharge' + if self.linear_discharge_var.get(): + soc_ratio = soc_percent / 100.0 + V_current = V_stop + (V_range * soc_ratio) + return V_current + + # --- REAL EMULATION MODE: Simplified S-Curve--- + # 1. Top range (100% - 90%): fast slope after charge + if soc_percent > 90: + V_drop_initial = V_range * 0.1 + V_current = V_start - ((100 - soc_percent) * (V_drop_initial / 10)) + return V_current + + # 2. Plateau (90% - 20%): flat one, at the end a slow down slope (70% of SoC range). + elif soc_percent > 20: + V_plateau_start = V_start - (V_range * 0.1) + V_plateau_end = V_stop + (V_range * 0.2) + + V_plateau_drop = V_plateau_start - V_plateau_end + + # scale to 70% (90 - 20 = 70) + V_current = V_plateau_start - ((90 - soc_percent) * (V_plateau_drop / 70)) + return V_current + + # 3. Low range (20% - 0%): (knee) (last of 20% SoC). + else: + V_plateau_end = V_stop + (V_range * 0.2) + + # scale on the 20% range + V_current = V_stop + (soc_percent * (V_plateau_end - V_stop) / 20) + return V_current + + # --- Voltage/SoC Mapping Functions (Retained from original script) --- + def get_soc_percent_from_voltage(self, voltage): + """Reverse calculation: Estimates SoC from voltage (used for setup).""" + V_start = self.config["V_START"] + V_stop = self.config["V_STOP"] + + if V_start <= V_stop: return 100.0 + + if voltage >= V_start: return 100.0 + if voltage <= V_stop: return 0.0 + + V_range = V_start - V_stop + + # --- TEST MODE: Linear Slope --- + if self.linear_discharge_var.get(): + # Linear mode - linear function. + voltage_drop = voltage - V_stop + soc_percent = (voltage_drop / V_range) * 100.0 + return max(0.0, min(100.0, soc_percent)) + + # --- REAL EMULATION MODE: Simplified S-Curve -- + V_drop_10_percent = V_range * 0.1 + V_plateau_start = V_start - V_drop_10_percent + V_plateau_end = V_stop + V_range * 0.2 # New boundary of plateau + + # 1. High range (100% - 90%) + if voltage > V_plateau_start: + return 100.0 - ((V_start - voltage) / V_drop_10_percent) * 10.0 + + # 2. Low range (0% - 20%) + if voltage < V_plateau_end: + V_final_range = V_plateau_end - V_stop + if V_final_range <= 0: return 20.0 + return ((voltage - V_stop) / V_final_range) * 20.0 + + # 3. Plateau (20% - 90%) + else: + V_plateau_drop = V_plateau_start - V_plateau_end + if V_plateau_drop <= 0: return 90.0 + return 90.0 - ((V_plateau_start - voltage) / V_plateau_drop) * 70.0 + + def emulation_loop(self): + last_emulation_time = time.time() + is_sim_discharge = self.sim_discharge_var.get() + + while self.is_running and self.current_voltage > self.config["V_STOP"]: + + if not self.is_running: + break + + # 1. CURRENT AND LOGIC DATA MEASUREMENT + try: + currents_ma, all_logic_samples = self.ppk2.get_measurements() + logic_data = all_logic_samples[-1] if all_logic_samples else [False] * 8 + except Exception as e: + print(f"Error fetching data: {e}") + self.is_running = False + break + + if not currents_ma or len(currents_ma) == 0: + time.sleep(0.01) + continue + + currents_ma = np.array(currents_ma) + + # --- ENERGY CONSUMPTION LOGIC --- + + time_h_simulated = TIME_STEP_REAL_SEC / 3600.0 + I_avg_ma_realtime = np.mean(currents_ma) + + # 2. Update time elapsed + self.time_elapsed_real_sec += TIME_STEP_REAL_SEC + + if is_sim_discharge: + # SIMULATED DISCHARGE MODE (LINEAR V-SoC TEST): Capacity loss is time-based (FIXED - linear SoC drop). + + # Calculate the required capacity loss for this step + required_mah_loss_step = self.required_mah_loss_per_sec * TIME_STEP_REAL_SEC + self.current_capacity_mah -= required_mah_loss_step + + # Calculate SoC (Linear drop) + soc_percent = (self.current_capacity_mah / self.config["CAPACITY_NOMINAL_MAH"]) * 100.0 + + # Calculate Voltage (Linear - follows the linear V-SoC curve for this mode) + self.current_voltage = self.get_voltage_from_soc(soc_percent) + + # Check for end condition + if self.time_elapsed_real_sec >= self.total_sim_time_sec or soc_percent <= 0: + self.current_voltage = self.config["V_STOP"] # Ensure it hits V_STOP + self.is_running = False + break + + # Time remaining calculation (simple countdown) + time_remaining_sec = max(0, self.total_sim_time_sec - self.time_elapsed_real_sec) + + else: + # REAL EMULATION MODE: Voltage loss is current-based (S-CURVE). + + consumed_mah = I_avg_ma_realtime * time_h_simulated + self.current_capacity_mah -= consumed_mah + soc_percent = (self.current_capacity_mah / self.config["CAPACITY_NOMINAL_MAH"]) * 100.0 + self.current_voltage = self.get_voltage_from_soc(soc_percent) + + # Estimated remaining time is based on current consumption rate + mah_remaining = self.current_capacity_mah - self.config[ + "CAPACITY_NOMINAL_MAH"] * self.get_soc_percent_from_voltage(self.config["V_STOP"]) / 100.0 + time_remaining_sec = (mah_remaining / I_avg_ma_realtime) * 3600.0 if I_avg_ma_realtime > 0 else float( + 'inf') + + # 3. UPDATE VOLTAGE ON PPK2 + self.ppk2.set_voltage(self.current_voltage) + + # 4. Update Logs + + for i, current_sample in enumerate(currents_ma): + # Log the current voltage for every sample in this step + self.all_current_samples.append(current_sample) + self.all_time_real_s.append(self.time_elapsed_real_sec) + self.all_voltage_v.append(self.current_voltage) + + if self.logic_enabled_var.get(): + self.all_logic_data.append(logic_data) + else: + self.all_logic_data.append([False] * 8) + + # Calculate statistics + peak_current = np.max(currents_ma) + rms_current = np.sqrt(np.mean(currents_ma ** 2)) + + # --- CONSOLE DEBUG OUTPUT --- + # Print every 5 seconds (5 steps of 1.0s) + if int(self.time_elapsed_real_sec * 10) % 50 == 0 or self.time_elapsed_real_sec == self.total_sim_time_sec: + time_rem_str = self.format_seconds_to_hms(time_remaining_sec) + + print( + f"TIME: {self.time_elapsed_real_sec:.1f} s | " + f"V_OUT: {self.current_voltage:.3f} V | " + f"I_AVG: {I_avg_ma_realtime:.2f} mA | " + f"SoC: {soc_percent:.1f}% | " + f"Rem. Time: {time_rem_str}" + f" | Linear mode {self.linear_discharge_var.get()}" + ) + + # Update GUI (Thread-safe way) + self.master.after(0, self.update_gui, soc_percent, I_avg_ma_realtime, peak_current, rms_current, + currents_ma, logic_data) + + # Wait for the next measurement step + time.sleep(max(0, TIME_STEP_EMUL_SEC - (time.time() - last_emulation_time))) + last_emulation_time = time.time() + + # Final cleanup if loop exited + print("--- EMULATION LOOP EXIT ---") + self.master.after(0, self.stop_emulation) + if self.current_voltage <= self.config["V_STOP"] or is_sim_discharge: + self.master.after(0, self.display_final_results) + + # UPDATED FUNCTION (CORRECTED CHARGE IN COULOMBS) + def display_final_results(self): + total_time_real_sec = self.time_elapsed_real_sec + total_time_real_h = total_time_real_sec / 3600.0 + + # Format runtime to HH:MM:SS for the final message + runtime_hms = self.format_seconds_to_hms(total_time_real_sec) + + if self.sim_discharge_var.get(): + # In time mode, we simulate the discharge of 100% capacity in a specified time. + mah_consumed_simulated = self.config["CAPACITY_NOMINAL_MAH"] + + I_test_avg_ma = mah_consumed_simulated / total_time_real_h if total_time_real_h > 0 else 0 + + # proper coulomb conversion (1 mAh = 3.6 C) + total_charge_coulombs = mah_consumed_simulated * 3.6 + + final_message = ( + f"!!! SIMULATION FINISHED (Linear V-SoC Test Mode) !!!\n" + f"Target Simulation Time: {self.sim_time_str_var.get()} (MM:SS).\n" + f"Discharge from {self.config['V_START']:.2f}V to {self.config['V_STOP']:.2f}V reached.\n" + f"\n--- RESULTS ---\n" + f"Consumed Charge (C): {total_charge_coulombs:.2f} C\n" + f"Simulated Runtime: {runtime_hms} (HH:MM:SS)\n" + f"Theoretical Average Current Drawn: {I_test_avg_ma:.2f} mA (to achieve V drop)" + ) + + else: + total_consumed_mah = self.config["CAPACITY_NOMINAL_MAH"] - self.current_capacity_mah + + # proper conversion (1 mAh = 3.6 C) + total_charge_coulombs = total_consumed_mah * 3.6 + + I_test_avg_ma = total_consumed_mah / total_time_real_h if total_time_real_h > 0 else 0 + estimated_runtime_h = self.config["CAPACITY_NOMINAL_MAH"] / I_test_avg_ma if I_test_avg_ma > 0 else float( + 'inf') + estimated_runtime_hms = self.format_seconds_to_hms(estimated_runtime_h * 3600.0) + + final_message = ( + f"!!! EMULATION FINISHED !!!\n" + f"The algorithm reached V_STOP = {self.config['V_STOP']} V.\n" + f"\n--- TEST RESULTS ---\n" + f"Consumed Charge (C): {total_charge_coulombs:.2f} C\n" + f"Simulated Runtime until V_STOP: {runtime_hms} (HH:MM:SS)\n" + f"Overall Average Current from test: {I_test_avg_ma:.2f} mA\n" + f"*** ESTIMATED TOTAL RUNTIME (Full Capacity): {estimated_runtime_hms} (HH:MM:SS) ***" + ) + + messagebox.showinfo("Simulation Results", final_message) + + def update_gui(self, soc_percent, I_avg_ma_realtime, peak_current, rms_current, currents_to_plot, logic_data): + # Update Labels + self.v_label.config(text=f"V_SYS Voltage (V): {self.current_voltage:.3f}") + self.soc_label.config(text=f"SoC (%): {soc_percent:.1f} (Avg I: {I_avg_ma_realtime:.1f} mA)") + + # Display runtime in HH:MM:SS format + runtime_hms = self.format_seconds_to_hms(self.time_elapsed_real_sec) + self.runtime_label.config(text=f"Real Runtime (H:M:S): {runtime_hms}") + + self.peak_label.config(text=f"Peak Current (mA): {peak_current:.2f}") + self.rms_label.config(text=f"RMS Current (mA): {rms_current:.2f}") + + mode = "LINEAR V-SoC TEST" if self.linear_discharge_var.get() else "CURRENT EMULATION (S-Curve)" + self.master.title(f"PPK2 [{API_MODE}] - Battery Simulator ({mode})") + + # Update Logic Indicators + for i, val in enumerate(logic_data): + color = 'green' if val else 'red' + text = 'HIGH' if val else 'LOW' + self.logic_indicators[i].config(text=f"D{i}: {text}", background=color) + + # Update Plots (Oscilloscope) + plot_times = np.arange(0, len(currents_to_plot)) / self.config["SAMPLE_RATE_HZ"] + + # Current Plot + self.line_i.set_data(plot_times, currents_to_plot) + self.ax_i.set_xlim(0, plot_times[-1] if plot_times.size > 0 else 0.01) + self.ax_i.set_ylim(np.min(currents_to_plot) * 0.9, + np.max(currents_to_plot) * 1.1 if currents_to_plot.size > 0 else 10) + + # Voltage Plot + voltage_plot = np.full_like(plot_times, self.current_voltage) + self.line_v.set_data(plot_times, voltage_plot) + self.ax_v.set_ylim(self.config["V_STOP"] * 0.9, self.config["V_START"] * 1.1) + + self.fig.tight_layout() + self.canvas.draw() + + # + def save_data(self): + """Saves collected data to a CSV file and appends summary statistics. + Includes an aggregated Hex value for the Logic Analyzer pins.""" + if not self.all_current_samples: + messagebox.showwarning("Error", "No data to save.") + return + + # --- Generate File Name with Date/Time --- + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_filename = f"{timestamp}_ppk2_battery_log.csv" + + filepath = filedialog.asksaveasfilename(defaultextension=".csv", + filetypes=[("CSV files", "*.csv")], + initialfile=default_filename) + if not filepath: + return + + # --- 1. Prepare Time Data in s:ms format --- + def format_time_s_ms(total_seconds): + seconds = int(total_seconds) + milliseconds = int(round((total_seconds - seconds) * 1000)) + if milliseconds == 1000: # Handle rounding up to the next second + seconds += 1 + milliseconds = 0 + return f"{seconds}:{milliseconds:03d}" + + time_s_ms = [format_time_s_ms(t) for t in self.all_time_real_s] + + # --- 2. Bit-to-Hex Conversion --- + def logic_to_hex(logic_list): + """ + Converts a list of 8 booleans (D0 is LSB) to a 0xXX hex string. + Now prepends '0x' for standard hex format. + """ + if not logic_list: + return '0x00' + + logic_list = logic_list[:8] + [False] * (8 - len(logic_list)) + + # Assuming D0 is LSB (2^0) and D7 is MSB (2^7). + decimal_value = 0 + for i, state in enumerate(logic_list): + if state: + decimal_value += (2 ** i) + + # Return with '0x' prefix + return f'0x{decimal_value:02X}' + + # --- 3. Prepare Data Frame for main log --- + min_len = min(len(time_s_ms), len(self.all_current_samples), len(self.all_voltage_v), len(self.all_logic_data)) + + # Create columns for logic data (D0, D1, ..., D7) + logic_cols = {f"D{i}": [log[i] for log in self.all_logic_data[:min_len]] for i in range(8)} + + # New Hexadecimal column + logic_hex_values = [logic_to_hex(log) for log in self.all_logic_data[:min_len]] + + data_dict = { + "Time_s:ms": time_s_ms[:min_len], + "Current_mA_Measured": self.all_current_samples[:min_len], + "Voltage_V_Emulated": self.all_voltage_v[:min_len], + **logic_cols, + "Logic_Hex_D0-D7": logic_hex_values + } + df = pd.DataFrame(data_dict) + + # --- 4. Calculate Summary Statistics --- + total_time_real_sec = self.time_elapsed_real_sec + total_time_real_h = total_time_real_sec / 3600.0 + + # Format runtime to HH:MM:SS for the summary + runtime_hms = self.format_seconds_to_hms(total_time_real_sec) + + if self.sim_discharge_var.get(): + # Time Simulation Mode (Linear V-SoC Test) - Total capacity used + mah_consumed_simulated = self.config["CAPACITY_NOMINAL_MAH"] + + I_avg_ma = mah_consumed_simulated / total_time_real_h if total_time_real_h > 0 else 0 + I_peak_ma = np.max(self.all_current_samples) if self.all_current_samples else 0 + + total_charge_coulombs = mah_consumed_simulated * 3.6 + estimated_runtime_hms = self.format_seconds_to_hms(self.total_sim_time_sec) + + else: + # Current Emulation Mode (S-Curve) + total_consumed_mah = self.config["CAPACITY_NOMINAL_MAH"] - self.current_capacity_mah + + total_charge_coulombs = total_consumed_mah * 3.6 + + I_avg_ma = total_consumed_mah / total_time_real_h if total_time_real_h > 0 else 0 + I_peak_ma = np.max(self.all_current_samples) if self.all_current_samples else 0 + + estimated_runtime_h = self.config["CAPACITY_NOMINAL_MAH"] / I_avg_ma if I_avg_ma > 0 else float('inf') + estimated_runtime_hms = self.format_seconds_to_hms(estimated_runtime_h * 3600.0) + + # --- 5. Create Summary Block --- + summary_lines = [ + f"\n\n--- SIMULATION SUMMARY ---", + f"Mode,{'LINEAR_V_SOC_TEST' if self.sim_discharge_var.get() else 'CURRENT_EMULATION_S_CURVE'}", + f"Battery_Type,{self.battery_type_var.get()}", + f"Nominal_Capacity_mAh,{self.config['CAPACITY_NOMINAL_MAH']:.2f}", + f"Time_to_V_STOP_s,{self.time_elapsed_real_sec:.2f}", + f"Total_Charge_Consumed_C,{total_charge_coulombs:.2f}", + f"Average_Current_mA,{I_avg_ma:.2f}", + f"Peak_Current_mA,{I_peak_ma:.2f}", + f"Runtime_to_V_STOP_H:M:S,{runtime_hms}", # Use H:M:S format here + f"Estimated_Total_Runtime_H:M:S,{estimated_runtime_hms}", + f"V_START_V,{self.config['V_START']:.2f}", + f"V_STOP_V,{self.config['V_STOP']:.2f}", + f"--------------------------" + ] + summary_block = "\n".join(summary_lines) + + # --- 6. Save to CSV --- + with open(filepath, 'w') as f: + # Write data frame (main log) + f.write(df.to_csv(index=False)) + # Append summary block + f.write(summary_block) + + messagebox.showinfo("Saved", f"Data and Summary successfully saved to:\n{filepath}") + + +# --- Main application loop --- +if __name__ == "__main__": + # Ensure dependencies are available (though no guarantee) + try: + import numpy as np + import pandas as pd + import matplotlib.pyplot as plt + except ImportError: + print( + "FATAL ERROR: Missing dependencies (numpy, pandas, matplotlib). Please install them using: pip install numpy pandas matplotlib") + exit() + + root = tk.Tk() + app = BatteryEmulator(master=root) + app.pack(fill="both", expand=True) + + + def on_closing(): + if messagebox.askokcancel("Quit", "Do you want to quit the application?"): + app.stop_emulation() + root.destroy() + + + root.protocol("WM_DELETE_WINDOW", on_closing) + root.mainloop() \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4001.yaml b/tools/smu-ppk2-gui/components/1N4001.yaml new file mode 100644 index 0000000..8cc3548 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4001.yaml @@ -0,0 +1,9 @@ +# components/1N4001.yaml +description: "1N4001 rectifier diode – 50 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +sweep_v2: [0.0, 0.0, 1] +delay: 0.05 +pd_max: 1.0 +t_max: 175 +log_scale: false \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4002.yaml b/tools/smu-ppk2-gui/components/1N4002.yaml new file mode 100644 index 0000000..3d5dd87 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4002.yaml @@ -0,0 +1,6 @@ +# components/1N4002.yaml +description: "1N4002 rectifier diode – 100 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +pd_max: 1.0 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4003.yaml b/tools/smu-ppk2-gui/components/1N4003.yaml new file mode 100644 index 0000000..c9fe641 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4003.yaml @@ -0,0 +1,6 @@ +# components/1N4003.yaml +description: "1N4003 rectifier diode – 200 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +pd_max: 1.0 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4004.yaml b/tools/smu-ppk2-gui/components/1N4004.yaml new file mode 100644 index 0000000..229753a --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4004.yaml @@ -0,0 +1,6 @@ +# components/1N4004.yaml +description: "1N4004 rectifier diode – 400 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +pd_max: 1.0 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4005.yaml b/tools/smu-ppk2-gui/components/1N4005.yaml new file mode 100644 index 0000000..dbdad02 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4005.yaml @@ -0,0 +1,6 @@ +# components/1N4005.yaml +description: "1N4005 rectifier diode – 600 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +pd_max: 1.0 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4006.yaml b/tools/smu-ppk2-gui/components/1N4006.yaml new file mode 100644 index 0000000..05a09d5 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4006.yaml @@ -0,0 +1,6 @@ +# components/1N4006.yaml +description: "1N4006 rectifier diode – 800 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +pd_max: 1.0 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4007.yaml b/tools/smu-ppk2-gui/components/1N4007.yaml new file mode 100644 index 0000000..6896570 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4007.yaml @@ -0,0 +1,6 @@ +# components/1N4007.yaml +description: "1N4007 rectifier diode – 1000 V, 1 A" +v1_typical: 0.7 +sweep_v1: [0.0, 2.0, 100] +pd_max: 1.0 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N4148.yaml b/tools/smu-ppk2-gui/components/1N4148.yaml new file mode 100644 index 0000000..70d607d --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N4148.yaml @@ -0,0 +1,7 @@ +# components/1N4148.yaml +description: "1N4148 fast switching diode" +v1_typical: 0.7 +sweep_v1: [0.0, 1.5, 80] +delay: 0.02 +pd_max: 0.5 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N5819.yaml b/tools/smu-ppk2-gui/components/1N5819.yaml new file mode 100644 index 0000000..eaebdc6 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N5819.yaml @@ -0,0 +1,6 @@ +# components/1N5819.yaml +description: "1N5819 Schottky diode 40 V 1 A" +v1_typical: 0.4 +sweep_v1: [0.0, 1.0, 100] +pd_max: 1.0 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/1N5822.yaml b/tools/smu-ppk2-gui/components/1N5822.yaml new file mode 100644 index 0000000..ac91008 --- /dev/null +++ b/tools/smu-ppk2-gui/components/1N5822.yaml @@ -0,0 +1,6 @@ +# components/1N5822.yaml +description: "1N5822 Schottky diode 40 V 3 A" +v1_typical: 0.45 +sweep_v1: [0.0, 1.0, 100] +pd_max: 3.0 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_2N2222.yaml b/tools/smu-ppk2-gui/components/bjt_2N2222.yaml new file mode 100644 index 0000000..8cea76f --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_2N2222.yaml @@ -0,0 +1,8 @@ +# components/bjt_2N2222.yaml +description: "2N2222 NPN transistor" +v1_typical: 0.7 +v2_typical: 10.0 +sweep_v1: [0.0, 1.2, 60] +sweep_v2: [0.0, 40.0, 60] +pd_max: 0.8 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_2N2907.yaml b/tools/smu-ppk2-gui/components/bjt_2N2907.yaml new file mode 100644 index 0000000..b9290f2 --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_2N2907.yaml @@ -0,0 +1,8 @@ +# components/bjt_2N2907.yaml +description: "2N2907 PNP transistor" +v1_typical: 0.7 +v2_typical: 10.0 +sweep_v1: [0.0, 1.2, 60] +sweep_v2: [0.0, 60.0, 60] +pd_max: 0.8 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_2N3904.yaml b/tools/smu-ppk2-gui/components/bjt_2N3904.yaml new file mode 100644 index 0000000..c9e129b --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_2N3904.yaml @@ -0,0 +1,8 @@ +# components/bjt_2N3904.yaml +description: "2N3904 NPN transistor" +v1_typical: 0.7 # Vbe +v2_typical: 5.0 # Vce +sweep_v1: [0.0, 1.0, 50] +sweep_v2: [0.0, 10.0, 50] +pd_max: 0.625 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_2N3906.yaml b/tools/smu-ppk2-gui/components/bjt_2N3906.yaml new file mode 100644 index 0000000..68f2983 --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_2N3906.yaml @@ -0,0 +1,8 @@ +# components/bjt_2N3906.yaml +description: "2N3906 PNP transistor" +v1_typical: 0.7 +v2_typical: 5.0 +sweep_v1: [0.0, 1.0, 50] +sweep_v2: [0.0, 10.0, 50] +pd_max: 0.625 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_BC547.yaml b/tools/smu-ppk2-gui/components/bjt_BC547.yaml new file mode 100644 index 0000000..d26aab2 --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_BC547.yaml @@ -0,0 +1,8 @@ +# components/bjt_BC547.yaml +description: "BC547 NPN transistor" +v1_typical: 0.7 +v2_typical: 5.0 +sweep_v1: [0.0, 1.0, 50] +sweep_v2: [0.0, 30.0, 50] +pd_max: 0.5 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_BC557.yaml b/tools/smu-ppk2-gui/components/bjt_BC557.yaml new file mode 100644 index 0000000..1ebeb72 --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_BC557.yaml @@ -0,0 +1,8 @@ +# components/bjt_BC557.yaml +description: "BC557 PNP transistor" +v1_typical: 0.7 +v2_typical: 5.0 +sweep_v1: [0.0, 1.0, 50] +sweep_v2: [0.0, 45.0, 50] +pd_max: 0.5 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/bjt_TIP31.yaml b/tools/smu-ppk2-gui/components/bjt_TIP31.yaml new file mode 100644 index 0000000..4fae47b --- /dev/null +++ b/tools/smu-ppk2-gui/components/bjt_TIP31.yaml @@ -0,0 +1,8 @@ +# components/bjt_TIP31.yaml +description: "TIP31 NPN power transistor" +v1_typical: 0.7 +v2_typical: 20.0 +sweep_v1: [0.0, 1.5, 50] +sweep_v2: [0.0, 100.0, 50] +pd_max: 40 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/cap_100nF.yaml b/tools/smu-ppk2-gui/components/cap_100nF.yaml new file mode 100644 index 0000000..354b544 --- /dev/null +++ b/tools/smu-ppk2-gui/components/cap_100nF.yaml @@ -0,0 +1,7 @@ +# components/cap_100nF.yaml +description: "100 nF ceramic capacitor 50 V" +v1_typical: 5.0 +sweep_v1: [0.0, 50.0, 100] +delay: 0.5 +pd_max: 0.01 +log_scale: true \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/diac_DB3.yaml b/tools/smu-ppk2-gui/components/diac_DB3.yaml new file mode 100644 index 0000000..f6a3e6c --- /dev/null +++ b/tools/smu-ppk2-gui/components/diac_DB3.yaml @@ -0,0 +1,7 @@ +# components/diac_DB3.yaml +description: "DB3 diac – Vbo ≈ 32 V" +v1_typical: 0.0 +sweep_v1: [-40.0, 40.0, 160] +delay: 0.2 +pd_max: 0.1 +t_max: 125 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_blue_5mm.yaml b/tools/smu-ppk2-gui/components/led_blue_5mm.yaml new file mode 100644 index 0000000..752ed83 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_blue_5mm.yaml @@ -0,0 +1,6 @@ +# components/led_blue_5mm.yaml +description: "Standard blue 5 mm LED – 3.3 V, 20 mA" +v1_typical: 3.3 +sweep_v1: [0.0, 4.0, 100] +pd_max: 0.1 +t_max: 85 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_green_5mm.yaml b/tools/smu-ppk2-gui/components/led_green_5mm.yaml new file mode 100644 index 0000000..4364ec5 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_green_5mm.yaml @@ -0,0 +1,6 @@ +# components/led_green_5mm.yaml +description: "Standard green 5 mm LED – 2.2 V, 20 mA" +v1_typical: 2.2 +sweep_v1: [0.0, 3.5, 100] +pd_max: 0.1 +t_max: 85 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_high_power_1W.yaml b/tools/smu-ppk2-gui/components/led_high_power_1W.yaml new file mode 100644 index 0000000..cd9247b --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_high_power_1W.yaml @@ -0,0 +1,6 @@ +# components/led_high_power_1W.yaml +description: "1 W high-power LED – 3.3 V, 350 mA" +v1_typical: 3.3 +sweep_v1: [0.0, 4.0, 100] +pd_max: 1.0 +t_max: 100 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_ir_940nm.yaml b/tools/smu-ppk2-gui/components/led_ir_940nm.yaml new file mode 100644 index 0000000..f5a4645 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_ir_940nm.yaml @@ -0,0 +1,6 @@ +# components/led_ir_940nm.yaml +description: "Infrared LED 940 nm – 1.3 V, 100 mA" +v1_typical: 1.3 +sweep_v1: [0.0, 2.0, 100] +pd_max: 0.15 +t_max: 85 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_red_5mm.yaml b/tools/smu-ppk2-gui/components/led_red_5mm.yaml new file mode 100644 index 0000000..a1350f3 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_red_5mm.yaml @@ -0,0 +1,7 @@ +# components/led_red_5mm.yaml +description: "Standard red 5 mm LED – 2 V, 20 mA" +v1_typical: 2.0 +sweep_v1: [0.0, 3.0, 100] +pd_max: 0.1 +t_max: 85 +log_scale: false \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_rgb_common_cathode.yaml b/tools/smu-ppk2-gui/components/led_rgb_common_cathode.yaml new file mode 100644 index 0000000..acccd3c --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_rgb_common_cathode.yaml @@ -0,0 +1,6 @@ +# components/led_rgb_common_cathode.yaml +description: "RGB LED common cathode (test one color at a time)" +v1_typical: 2.0 +sweep_v1: [0.0, 3.5, 100] +pd_max: 0.1 +t_max: 85 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_smd_3535.yaml b/tools/smu-ppk2-gui/components/led_smd_3535.yaml new file mode 100644 index 0000000..a629de4 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_smd_3535.yaml @@ -0,0 +1,6 @@ +# components/led_smd_3535.yaml +description: "SMD 3535 LED – 3.2 V, 150 mA" +v1_typical: 3.2 +sweep_v1: [0.0, 4.0, 100] +pd_max: 0.5 +t_max: 90 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_uv_5mm.yaml b/tools/smu-ppk2-gui/components/led_uv_5mm.yaml new file mode 100644 index 0000000..734ca48 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_uv_5mm.yaml @@ -0,0 +1,6 @@ +# components/led_uv_5mm.yaml +description: "UV LED 400 nm – 3.4 V, 20 mA" +v1_typical: 3.4 +sweep_v1: [0.0, 4.5, 100] +pd_max: 0.1 +t_max: 80 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_white_5mm.yaml b/tools/smu-ppk2-gui/components/led_white_5mm.yaml new file mode 100644 index 0000000..ee9a743 --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_white_5mm.yaml @@ -0,0 +1,6 @@ +# components/led_white_5mm.yaml +description: "Standard white 5 mm LED – 3.2 V, 20 mA" +v1_typical: 3.2 +sweep_v1: [0.0, 4.0, 100] +pd_max: 0.1 +t_max: 85 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/led_yellow_5mm.yaml b/tools/smu-ppk2-gui/components/led_yellow_5mm.yaml new file mode 100644 index 0000000..f2a3d5c --- /dev/null +++ b/tools/smu-ppk2-gui/components/led_yellow_5mm.yaml @@ -0,0 +1,6 @@ +# components/led_yellow_5mm.yaml +description: "Standard yellow 5 mm LED – 2.1 V, 20 mA" +v1_typical: 2.1 +sweep_v1: [0.0, 3.0, 100] +pd_max: 0.1 +t_max: 85 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/mosfet_2N7000.yaml b/tools/smu-ppk2-gui/components/mosfet_2N7000.yaml new file mode 100644 index 0000000..780c598 --- /dev/null +++ b/tools/smu-ppk2-gui/components/mosfet_2N7000.yaml @@ -0,0 +1,8 @@ +# components/mosfet_2N7000.yaml +description: "2N7000 N-channel MOSFET 60 V 200 mA" +v1_typical: 3.0 +v2_typical: 20.0 +sweep_v1: [0.0, 5.0, 50] +sweep_v2: [0.0, 60.0, 50] +pd_max: 0.4 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/mosfet_BS170.yaml b/tools/smu-ppk2-gui/components/mosfet_BS170.yaml new file mode 100644 index 0000000..9b3374e --- /dev/null +++ b/tools/smu-ppk2-gui/components/mosfet_BS170.yaml @@ -0,0 +1,8 @@ +# components/mosfet_BS170.yaml +description: "BS170 N-channel MOSFET 60 V 500 mA" +v1_typical: 2.5 +v2_typical: 20.0 +sweep_v1: [0.0, 5.0, 50] +sweep_v2: [0.0, 60.0, 50] +pd_max: 0.8 +t_max: 150 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/mosfet_FQP30N06L.yaml b/tools/smu-ppk2-gui/components/mosfet_FQP30N06L.yaml new file mode 100644 index 0000000..8b85592 --- /dev/null +++ b/tools/smu-ppk2-gui/components/mosfet_FQP30N06L.yaml @@ -0,0 +1,8 @@ +# components/mosfet_FQP30N06L.yaml +description: "FQP30N06L logic-level N-channel 60 V 32 A" +v1_typical: 3.3 +v2_typical: 12.0 +sweep_v1: [0.0, 5.0, 100] +sweep_v2: [0.0, 40.0, 100] +pd_max: 79 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/mosfet_IRF540.yaml b/tools/smu-ppk2-gui/components/mosfet_IRF540.yaml new file mode 100644 index 0000000..4e97613 --- /dev/null +++ b/tools/smu-ppk2-gui/components/mosfet_IRF540.yaml @@ -0,0 +1,8 @@ +# components/mosfet_IRF540.yaml +description: "IRF540 N-channel MOSFET 100 V 33 A" +v1_typical: 4.0 # Vgs +v2_typical: 10.0 # Vds +sweep_v1: [0.0, 10.0, 100] +sweep_v2: [0.0, 50.0, 100] +pd_max: 150 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/mosfet_IRF9540.yaml b/tools/smu-ppk2-gui/components/mosfet_IRF9540.yaml new file mode 100644 index 0000000..35ed33d --- /dev/null +++ b/tools/smu-ppk2-gui/components/mosfet_IRF9540.yaml @@ -0,0 +1,8 @@ +# components/mosfet_IRF9540.yaml +description: "IRF9540 P-channel MOSFET –100 V, 23 A" +v1_typical: -4.0 +v2_typical: -10.0 +sweep_v1: [-10.0, 0.0, 100] +sweep_v2: [-50.0, 0.0, 100] +pd_max: 150 +t_max: 175 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/resistor_1k.yaml b/tools/smu-ppk2-gui/components/resistor_1k.yaml new file mode 100644 index 0000000..59d8188 --- /dev/null +++ b/tools/smu-ppk2-gui/components/resistor_1k.yaml @@ -0,0 +1,6 @@ +# components/resistor_1k.yaml +description: "1 kΩ resistor 0.25 W" +v1_typical: 5.0 +sweep_v1: [0.0, 15.0, 100] +pd_max: 0.25 +t_max: 155 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/triac_BT136.yaml b/tools/smu-ppk2-gui/components/triac_BT136.yaml new file mode 100644 index 0000000..ef5ceda --- /dev/null +++ b/tools/smu-ppk2-gui/components/triac_BT136.yaml @@ -0,0 +1,8 @@ +# components/triac_BT136.yaml +description: "BT136 triac 600 V 4 A" +v1_typical: 0.0 +sweep_v1: [-400.0, 400.0, 200] +delay: 0.3 +pd_max: 10 +t_max: 125 +log_scale: false \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/triac_BTA16.yaml b/tools/smu-ppk2-gui/components/triac_BTA16.yaml new file mode 100644 index 0000000..13b33ec --- /dev/null +++ b/tools/smu-ppk2-gui/components/triac_BTA16.yaml @@ -0,0 +1,7 @@ +# components/triac_BTA16.yaml +description: "BTA16 triac 600 V 16 A" +v1_typical: 0.0 +sweep_v1: [-500.0, 500.0, 200] +delay: 0.3 +pd_max: 20 +t_max: 125 \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/zener_3V3.yaml b/tools/smu-ppk2-gui/components/zener_3V3.yaml new file mode 100644 index 0000000..0484aab --- /dev/null +++ b/tools/smu-ppk2-gui/components/zener_3V3.yaml @@ -0,0 +1,8 @@ +# components/zener_3V3.yaml +description: "3.3 V Zener diode 500 mW" +v1_typical: 0.0 +sweep_v1: [-6.0, 0.0, 100] +delay: 0.2 +pd_max: 0.5 +t_max: 150 +log_scale: true \ No newline at end of file diff --git a/tools/smu-ppk2-gui/components/zener_5V1.yaml b/tools/smu-ppk2-gui/components/zener_5V1.yaml new file mode 100644 index 0000000..792f3fe --- /dev/null +++ b/tools/smu-ppk2-gui/components/zener_5V1.yaml @@ -0,0 +1,7 @@ +# components/zener_5V1.yaml +description: "5.1 V Zener diode 500 mW" +v1_typical: 0.0 +sweep_v1: [-10.0, 0.0, 100] +pd_max: 0.5 +t_max: 150 +log_scale: true \ No newline at end of file diff --git a/tools/smu-ppk2-gui/requirements.txt b/tools/smu-ppk2-gui/requirements.txt new file mode 100644 index 0000000..85f78d1 --- /dev/null +++ b/tools/smu-ppk2-gui/requirements.txt @@ -0,0 +1,8 @@ +pyyaml +matplotlib +pillow +ppk2-api-python +requests +w1thermsensor +simple-spice # opcjonalnie, ale używam ręcznego generowania +cairosvg \ No newline at end of file diff --git a/tools/smu-ppk2-gui/schemas/1N4003_ppk1.png b/tools/smu-ppk2-gui/schemas/1N4003_ppk1.png new file mode 100644 index 0000000..9a46ef4 Binary files /dev/null and b/tools/smu-ppk2-gui/schemas/1N4003_ppk1.png differ diff --git a/tools/smu-ppk2-gui/schemas/1N4003_ppk2.png b/tools/smu-ppk2-gui/schemas/1N4003_ppk2.png new file mode 100644 index 0000000..0c285fb Binary files /dev/null and b/tools/smu-ppk2-gui/schemas/1N4003_ppk2.png differ diff --git a/tools/smu-ppk2-gui/setup.py b/tools/smu-ppk2-gui/setup.py new file mode 100644 index 0000000..7a4bd98 --- /dev/null +++ b/tools/smu-ppk2-gui/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup, find_packages + +def read_requirements(): + with open('requirements.txt') as f: + return [line.strip() for line in f if line and not line.startswith('#')] + +setup( + name='smu-ppk2-gui', + version='1.0.0', + description='Intuitive GUI for SMU Emulation using Nordic PPK2', + long_description=open('README.md').read(), + long_description_content_type='text/markdown', + author='RolandWa', + url='github.com/RolandWa/ppk2-api-python/tools/smu-ppk2-gui', + packages=find_packages('src'), + package_dir={'': 'src'}, + install_requires=read_requirements(), + python_requires='>=3.8', + entry_points={ + 'console_scripts': [ + 'smu-gui = gui_main:main' + ] + }, + include_package_data=True, + package_data={'': ['schemas/*.svg', 'schemas/*.jpg', 'components/*.yaml']}, +) \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/config_loader.py b/tools/smu-ppk2-gui/src/config_loader.py new file mode 100644 index 0000000..d94c16b --- /dev/null +++ b/tools/smu-ppk2-gui/src/config_loader.py @@ -0,0 +1,34 @@ +import os +import yaml + + +def load_components(config_dir="components"): + # 1. Uzyskaj katalog, w którym znajduje się bieżący skrypt (config_loader.py), czyli 'src'. + base_dir = os.path.dirname(os.path.abspath(__file__)) + + # 2. Przejdź do katalogu nadrzędnego (..) i dołącz 'components' + # To przechodzi z '\...src\' do '\...\' i dodaje 'components', dając ścieżkę do: + # C:\Users\RWache\...\smu-ppk2-gui\components + full_config_dir = os.path.join(base_dir, '..', config_dir) + + # Upewnij się, że pełna ścieżka jest znormalizowana + full_config_dir = os.path.normpath(full_config_dir) + + components = {} + + # 3. Weryfikacja i ładowanie + if not os.path.isdir(full_config_dir): + print(f"WARNING: Configuration directory not found at {full_config_dir}. Returning empty components.") + return components + + for file in os.listdir(full_config_dir): + if file.endswith(".yaml") or file.endswith(".yml"): + path = os.path.join(full_config_dir, file) + with open(path, "r", encoding="utf-8") as f: + name = file.rsplit(".", 1)[0] + try: + components[name] = yaml.safe_load(f) + except yaml.YAMLError as e: + print(f"Error loading YAML file {file}: {e}") + + return components \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/gui_main.py b/tools/smu-ppk2-gui/src/gui_main.py new file mode 100644 index 0000000..9014496 --- /dev/null +++ b/tools/smu-ppk2-gui/src/gui_main.py @@ -0,0 +1,370 @@ +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from config_loader import load_components +from smu_interface import PPK2SMU, API_AVAILABLE +from utils import calculate_power, check_limits, get_temperature +from schemas_handler import load_schema_image +from spice_exporter import export_to_spice +import csv +from datetime import datetime +import numpy as np +import time +import os # Import os for path manipulation + +# Conditional import of PPK2_API if available +PPK2_API = None +if API_AVAILABLE: + try: + from ppk2_api.ppk2_api import PPK2_API + except ImportError: + pass + + +class SMUApp: + def __init__(self, root): + self.root = root + self.root.title("SMU Emulator – Nordic PPK2 Pro") + self.version = "1.1.0" + self.components = load_components() + + self.use_second_ppk = tk.BooleanVar(value=False) + self.port1_var = tk.StringVar() + self.port2_var = tk.StringVar() + + # New: Serial number variables for display + self.serial1_var = tk.StringVar(value="N/A") + self.serial2_var = tk.StringVar(value="N/A") + + # New: Storage for detected device info (Port -> Serial) + self.device_info = {} + + self.smu = None + self.current_comp = None + self.last_data = None + + self.build_menu() + self.build_wizard() + + def build_menu(self): + menubar = tk.Menu(self.root) + self.root.config(menu=menubar) + + file_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="File", menu=file_menu) + # Changed menu item to reflect combined action + file_menu.add_command(label="Save Results (CSV & SPICE)", command=self.save_all_results) + file_menu.add_separator() + file_menu.add_command(label="Exit", command=self.root.quit) + + info_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="Help", menu=info_menu) + info_menu.add_command(label="About", command=lambda: messagebox.showinfo("About", + f"v{self.version}\nNordic PPK2 SMU Emulator")) + info_menu.add_command(label="Check for updates", command=self.check_update) + info_menu.add_command(label="Update", command=self.do_update) + info_menu.add_command(label="Reinstall", command=self.do_reinstall) + + def check_update(self): + pass + + def do_update(self): + pass + + def do_reinstall(self): + pass + + def build_wizard(self): + nb = ttk.Notebook(self.root) + nb.pack(fill='both', expand=True) + + # Step 1 – Configuration + f1 = ttk.Frame(nb, padding=20) + + # Component Selection + ttk.Label(f1, text="Component:").grid(row=0, column=0, sticky='w', pady=5) + self.comp_var = tk.StringVar() + cb = ttk.Combobox(f1, textvariable=self.comp_var, values=list(self.components.keys()), width=40) + cb.grid(row=0, column=1, columnspan=3, pady=5, sticky='ew') + cb.bind("<>", self.on_component_selected) + + # PPK2 Configuration + ttk.Checkbutton(f1, text="Use second PPK2 (e.g. for transistor tests)", + variable=self.use_second_ppk, command=self.on_component_selected).grid(row=1, column=0, + columnspan=4, pady=10, + sticky='w') + + # Port 1 + ttk.Label(f1, text="Port PPK2 #1:").grid(row=2, column=0, sticky='e') + ttk.Entry(f1, textvariable=self.port1_var).grid(row=2, column=1, pady=2, sticky='ew') + # Display Serial 1 + ttk.Label(f1, text="S/N:").grid(row=2, column=2, sticky='w', padx=5) + self.sn1_label = ttk.Label(f1, textvariable=self.serial1_var) + self.sn1_label.grid(row=2, column=3, pady=2, sticky='w') + + # Port 2 + ttk.Label(f1, text="Port PPK2 #2:").grid(row=3, column=0, sticky='e') + ttk.Entry(f1, textvariable=self.port2_var).grid(row=3, column=1, pady=2, sticky='ew') + # Display Serial 2 + ttk.Label(f1, text="S/N:").grid(row=3, column=2, sticky='w', padx=5) + self.sn2_label = ttk.Label(f1, textvariable=self.serial2_var) + self.sn2_label.grid(row=3, column=3, pady=2, sticky='w') + + # Auto-detection Button + detect_button = ttk.Button( + f1, + text="Auto-Detect PPK2", + command=self.detect_ppk2_ports + ) + detect_button.grid(row=4, column=0, columnspan=4, pady=10) + + # Separator + ttk.Separator(f1, orient='horizontal').grid(row=5, column=0, columnspan=4, sticky='ew', pady=10) + + # Parameter Display Section + ttk.Label(f1, text="Loaded Parameters:").grid(row=6, column=0, sticky='w', columnspan=4) + self.params_text = tk.Text(f1, height=8, width=50, wrap='word', state='disabled') + self.params_text.grid(row=7, column=0, columnspan=4, sticky='ew') + + # Adjust grid for the frame + f1.grid_columnconfigure(1, weight=1) + + nb.add(f1, text="1. Configuration") + + # Step 2 – Connection Diagram + self.schema_frame = ttk.Frame(nb, padding=20) + self.schema_label = ttk.Label(self.schema_frame, text="Select component → diagram will appear") + self.schema_label.pack() + nb.add(self.schema_frame, text="2. Connection Diagram") + + # Step 3 – Settings & Start + f3 = ttk.Frame(nb, padding=20) + self.temp_label = ttk.Label(f3, text="Temp: -- °C") + self.temp_label.grid(row=0, columnspan=2, pady=10) + self.root.after(500, self.update_temp) + + ttk.Button(f3, text="Start full test", command=self.full_test).grid(row=1, columnspan=2, pady=20) + nb.add(f3, text="3. Test and Run") + + # Step 4 – Plot + fig_frame = ttk.Frame(nb) + self.fig, self.ax = plt.subplots(figsize=(8, 6)) + self.canvas = FigureCanvasTkAgg(self.fig, fig_frame) + self.canvas.get_tk_widget().pack(fill='both', expand=True) + nb.add(fig_frame, text="4. Results Plot") + + def detect_ppk2_ports(self): + """ + Automatically detects connected PPK2 devices, sets port variables, + stores device info, and displays serial numbers. + """ + if not API_AVAILABLE or PPK2_API is None: + messagebox.showwarning("Warning", + "PPK2 API is not available (MOCK mode or missing API). Cannot detect ports.") + return + + try: + # list_devices() returns a list of tuples: [(port, serial_number), ...] + devices = PPK2_API.list_devices() + # Store device info (Port -> Serial) for later lookups + self.device_info = {port: sn for port, sn in devices} + + # Reset port/serial fields + self.port1_var.set("") + self.port2_var.set("") + self.serial1_var.set("N/A") + self.serial2_var.set("N/A") + + if not devices: + messagebox.showinfo("Port Detection", "No connected PPK2 devices found.") + return + + info_msg = f"Found {len(devices)} PPK2 devices:\n\n" + + # Filling ports and updating serials + for i, (port, serial_num) in enumerate(devices): + info_msg += f"Device #{i + 1}: Port={port}, S/N={serial_num}\n" + if i == 0: + self.port1_var.set(port) + self.serial1_var.set(serial_num) + elif i == 1: + self.port2_var.set(port) + self.serial2_var.set(serial_num) + + # Clear remaining port field if only one device is found + if len(devices) < 2: + self.port2_var.set("") + + messagebox.showinfo("Port Detection", info_msg + "\n\nPorts set automatically in detection order.") + + except Exception as e: + messagebox.showerror("API Error", f"Error during PPK2 device detection: {e}") + + def update_temp(self): + t = get_temperature() + self.temp_label.config(text=f"Temp: {t:.1f} °C" if t else "Temp: no sensor") + self.root.after(500, self.update_temp) + + def on_component_selected(self, event=None): + name = self.comp_var.get() + if name: + self.current_comp = self.components[name] + + # 1. Update Connection Diagram + component_data = self.current_comp + # Default to name if 'type' is missing in YAML, then convert to lowercase for file naming + component_type = component_data.get('type', name) + + img = load_schema_image(component_type, self.use_second_ppk.get()) + if img: + self.schema_label.configure(image=img) + self.schema_label.image = img + + # 2. Display Loaded Parameters + params_display = "" + for key, value in self.current_comp.items(): + # Format list/tuple values nicely + if isinstance(value, list) or isinstance(value, tuple): + value = str(value).strip('[]()') + params_display += f"{key}: {value}\n" + + self.params_text.config(state='normal') + self.params_text.delete('1.0', tk.END) + self.params_text.insert('1.0', params_display) + self.params_text.config(state='disabled') + + # 3. Update serial number display if a port is set and device info is available + port1 = self.port1_var.get() + port2 = self.port2_var.get() + + # Check if port1 is in device_info and update display + if port1 and port1 in self.device_info: + self.serial1_var.set(self.device_info[port1]) + elif not port1: + self.serial1_var.set("N/A") + + # Check if port2 is in device_info + if port2 and port2 in self.device_info: + self.serial2_var.set(self.device_info[port2]) + elif not port2: + self.serial2_var.set("N/A") + + def full_test(self): + if not self.current_comp: + messagebox.showerror("Error", "Select a component first") + return + + # 1. Get and display loaded configuration parameters + params = self.current_comp + sweep1 = params.get('sweep_v1', [0, 3.3, 50]) + sweep2 = params.get('sweep_v2', [0, 0, 1]) if self.use_second_ppk.get() else [0, 0, 1] + delay = params.get('delay', 0.1) + log_scale = params.get('log_scale', False) + + test_info = f"""--- Starting I-V Test --- +Component: {self.comp_var.get()} +PPK1 Sweep V: {sweep1[0]}V to {sweep1[1]}V ({int(sweep1[2])} steps) +PPK2 Sweep V: {sweep2[0]}V to {sweep2[1]}V ({int(sweep2[2])} steps) +Delay between points: {delay} s +Logarithmic Y Scale: {'Yes' if log_scale else 'No'} +------------------------------""" + print(test_info) + + # 2. SMU Initialization and test execution + if self.smu: + self.smu.close() + + port1 = self.port1_var.get() or None + port2 = self.port2_var.get() or None if self.use_second_ppk.get() else None + + self.smu = PPK2SMU(port1, port2) + + v1_data, i1_data, v2_data, i2_data, temps = self.smu.sweep( + sweep1[0], sweep1[1], int(sweep1[2]), + sweep2[0], sweep2[1], int(sweep2[2]), + delay + ) + + # 3. Process and display results + self.last_data = { + 'v1': v1_data, 'i1': i1_data, + 'v2': v2_data, 'i2': i2_data, + 'temp': temps, 'comp': self.comp_var.get() + } + + self.ax.clear() + style = 'b-o' if not log_scale else 'b-' + self.ax.plot(v1_data, i1_data, style, label="PPK2 #1") + if self.use_second_ppk.get() and any(i2_data): + self.ax.plot(v2_data, i2_data, 'r-o', label="PPK2 #2") + self.ax.legend() + self.ax.set_xlabel('Voltage [V]') + self.ax.set_ylabel('Current [A]') + self.ax.set_title(f"Test: {self.comp_var.get()}") + self.canvas.draw() + + messagebox.showinfo("Finished", "Measurement complete – use File menu to save/export") + + def save_all_results(self): + """ + Prompts user for a save location and exports both CSV and SPICE model + using a consistent filename base. + """ + if not self.last_data: + messagebox.showwarning("Warning", "No data available to save.") + return + + component_name = self.comp_var.get() + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + # Use component name and timestamp for default filename + default_name = f"{component_name}_{timestamp}" + + # Prompt user for save path using the CSV extension as the primary output format + save_path = filedialog.asksaveasfilename( + defaultextension=".csv", + initialfile=default_name, + filetypes=[("CSV and SPICE Files", "*.csv"), ("All Files", "*.*")] + ) + + if not save_path: + return + + # Determine the base filename and directory + save_dir = os.path.dirname(save_path) + base_name = os.path.splitext(os.path.basename(save_path))[0] + + # Save CSV + csv_path = os.path.join(save_dir, f"{base_name}.csv") + try: + with open(csv_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['V1 [V]', 'I1 [A]', 'V2 [V]', 'I2 [A]', 'Temp [°C]', 'Power [mW]']) + for i in range(len(self.last_data['v1'])): + v1 = self.last_data['v1'][i] + i1 = self.last_data['i1'][i] + v2 = self.last_data['v2'][i] if i < len(self.last_data['v2']) else 0 + i2 = self.last_data['i2'][i] if i < len(self.last_data['i2']) else 0 + t = self.last_data['temp'][i] + p = calculate_power(v1, i1) * 1000 + writer.writerow([f"{v1:.4f}", f"{i1:.6f}", f"{v2:.4f}", f"{i2:.6f}", f"{t:.1f}", f"{p:.1f}"]) + + # Export SPICE Model + lib_path = os.path.join(save_dir, f"{base_name}.lib") + export_to_spice(component_name, self.last_data['v1'], self.last_data['i1'], lib_path) + + messagebox.showinfo("Saved", f"Results saved successfully:\n- CSV: {csv_path}\n- SPICE: {lib_path}") + + except Exception as e: + messagebox.showerror("Save Error", f"An error occurred while saving: {e}") + + +def main(): + root = tk.Tk() + app = SMUApp(root) + root.geometry("1200x800") + root.mainloop() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/init.py b/tools/smu-ppk2-gui/src/init.py new file mode 100644 index 0000000..d538f87 --- /dev/null +++ b/tools/smu-ppk2-gui/src/init.py @@ -0,0 +1 @@ +__version__ = "1.0.0" \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/schemas_handler.py b/tools/smu-ppk2-gui/src/schemas_handler.py new file mode 100644 index 0000000..4a6ee34 --- /dev/null +++ b/tools/smu-ppk2-gui/src/schemas_handler.py @@ -0,0 +1,95 @@ +from PIL import Image, ImageTk +import os +import io + +# --- OBSŁUGA SVG: Wymaga instalacji cairosvg --- +try: + import cairosvg + + SVG_AVAILABLE = True +except ImportError: + SVG_AVAILABLE = False + + +def load_schema_image(comp_name, use_second_ppk=False, max_width=None, max_height=None): + """ + Loads the connection diagram image (SVG, PNG, or JPG) for the selected component, + automatically scaling it to fit the provided dimensions. + + Args: + comp_name (str): The name of the component (e.g., 'resistor'). + use_second_ppk (bool): Flag indicating if the two-PPK2 schema should be used. + max_width (int, optional): Maximum width for scaling. + max_height (int, optional): Maximum height for scaling. + + Returns: + ImageTk.PhotoImage: The loaded and scaled image object, or None if loading fails. + """ + folder = "schemas" + suffix = "_ppk2" if use_second_ppk else "_ppk1" + base_filename = f"{comp_name}{suffix}" + + # Definicja kolejności poszukiwania plików: SVG -> PNG -> JPG + # Ścieżka do folderu 'schemas' powinna być obliczana względem położenia schemas_handler.py + schemas_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', folder) + + path = None + img = None + + print(base_filename) + print(schemas_dir) + + # 1. Próba wczytania SVG + if SVG_AVAILABLE: + svg_path = os.path.join(schemas_dir, f"{base_filename}.svg") + if os.path.exists(svg_path): + try: + # Rasteryzacja SVG do bytow PNG w pamięci + # Użycie max_width/max_height w cairosvg dla lepszej jakości początkowej rasteryzacji + png_bytes = cairosvg.svg2png(url=svg_path, output_width=max_width, output_height=max_height) + img = Image.open(io.BytesIO(png_bytes)) + path = svg_path + except Exception as e: + print(f"Błąd rasteryzacji SVG {svg_path}: {e}") + + # 2. Próba wczytania PNG/JPG (tylko jeśli SVG nie działało lub nie było dostępne) + if img is None: + for ext in ['.png', '.jpg']: + temp_path = os.path.join(schemas_dir, f"{base_filename}{ext}") + if os.path.exists(temp_path): + path = temp_path + break + + # Wczytywanie z pliku PNG/JPG + if path: + try: + img = Image.open(path) + except Exception as e: + print(f"Błąd podczas wczytywania obrazu {path}: {e}") + return None + + if img is None: + return None + + # 3. Skalowanie obrazu (jeśli jest potrzebne i podano wymiary) + if max_width and max_height: + original_width, original_height = img.size + width_ratio = max_width / original_width + height_ratio = max_height / original_height + + # Użycie mniejszego współczynnika, aby obraz zmieścił się w całości + scale_factor = min(width_ratio, height_ratio) + + # Skalowanie w dół, lub lekkie skalowanie w górę (do 110%) + if scale_factor < 1.0 or scale_factor < 1.1: + new_width = int(original_width * scale_factor) + new_height = int(original_height * scale_factor) + # Użycie Image.Resampling.LANCZOS dla wysokiej jakości + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 4. Konwersja do formatu Tkinter i zwrócenie + try: + return ImageTk.PhotoImage(img) + except Exception as e: + print(f"Błąd konwersji obrazu do formatu Tkinter: {e}") + return None \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/smu_interface.py b/tools/smu-ppk2-gui/src/smu_interface.py new file mode 100644 index 0000000..59da890 --- /dev/null +++ b/tools/smu-ppk2-gui/src/smu_interface.py @@ -0,0 +1,165 @@ +# ---------------------------------------------------------------------- +# PPK2 API INTEGRATION (Adapter Classes) +# ---------------------------------------------------------------------- + +# --- API LOADING LOGIC --- +try: + # Attempt to import the real API components + from ppk2_api.ppk2_api import PPK2_API, PPK2_Command, PPK2_Modes + + API_AVAILABLE = True + API_MODE = "REAL" + print("INFO: PPK2 API found. Attempting REAL mode.") +except ImportError: + API_AVAILABLE = False + API_MODE = "MOCK" + print("WARNING: PPK2 API not found. Using MOCK mode for simulation.") + +# MOCK DEFINITION for PPK2_API (to allow code compilation in MOCK mode) + + +class PPK2_API: + def __init__(self, port=None): pass + + def use_source_meter(self): pass + + # PPK2 API works in mV, so set_source_voltage accepts mV + def set_source_voltage(self, voltage_mv): pass + + def start_acquisition(self): pass + + def stop_acquisition(self): pass + + def get_samples(self): + # API returns current in mA (PPK2 standard) + return {'current': [1.0], 'voltage': [3300]} + + def close(self): pass + + @staticmethod + def list_devices(): return [] # Mock for auto-detection + + +import time +import numpy as np +# Assuming utils.py is available and contains these functions +from utils import get_temperature, check_limits + + +class PPK2SMU: + def __init__(self, port1=None, port2=None): + + # Connect PPK1 + if API_AVAILABLE or API_MODE == "MOCK": + self.ppk1 = PPK2_API(port=port1) + self.ppk1.use_source_meter() + else: + raise EnvironmentError("PPK2 API is not available.") + + # Connect PPK2 (if port is provided) + self.ppk2 = None + if port2: + if API_AVAILABLE or API_MODE == "MOCK": + self.ppk2 = PPK2_API(port=port2) + self.ppk2.use_source_meter() + else: + raise EnvironmentError("PPK2 API is not available for PPK2.") + + def set_voltage_ppk1(self, voltage_v): + # Convert Volts (V) to millivolts (mV), expected by the API + self.ppk1.set_source_voltage(voltage_v * 1000) + + def set_voltage_ppk2(self, voltage_v): + if self.ppk2: + # Convert Volts (V) to millivolts (mV) + self.ppk2.set_source_voltage(voltage_v * 1000) + + def start_measurement(self): + # Measurement is typically handled inside the sweep loop + pass + + def stop_measurement(self): + pass + + def get_current_ppk1(self): + # API returns data in mA. Convert to A (Amperes) + data = self.ppk1.get_samples() + return (data['current'][0] / 1000) if data and data['current'] else 0.0 + + def get_current_ppk2(self): + if not self.ppk2: + return 0.0 + # API returns data in mA. Convert to A (Amperes) + data = self.ppk2.get_samples() + return (data['current'][0] / 1000) if data and data['current'] else 0.0 + + def close(self): + self.ppk1.close() + if self.ppk2: + self.ppk2.close() + + # UPDATED FUNCTION: SWEEP (Added current measurement logging) + def sweep(self, v1_start, v1_stop, v1_steps, v2_start, v2_stop, v2_steps, delay): + """ + Performs an I-V sweep test by gradually changing V1 and optionally V2 voltage. + + Returns: (v1_data, i1_data, v2_data, i2_data, temps) + """ + v1_data, i1_data, v2_data_out, i2_data_out, temps = [], [], [], [], [] + + if v1_steps <= 0: + print("WARNING: V1 steps must be greater than 0.") + return v1_data, i1_data, v2_data_out, i2_data_out, temps + + # Creating voltage vectors for PPK1 sweep + v1_range = np.linspace(v1_start, v1_stop, v1_steps) + # Creating voltage vectors for PPK2 sweep + v2_range = np.linspace(v2_start, v2_stop, v2_steps) + # Use only the first V2 point if sweep has only 1 step (constant voltage) + v2_iter = v2_range if len(v2_range) > 1 else [v2_range[0]] + + total_steps = len(v1_range) * len(v2_iter) + current_step = 0 + + # Main sweep loop (for PPK1) + for v1 in v1_range: + self.set_voltage_ppk1(v1) + + # Inner sweep loop (for PPK2) + for v2 in v2_iter: + current_step += 1 + + if self.ppk2: + self.set_voltage_ppk2(v2) + + # Wait for stabilization + time.sleep(delay) + + # Current measurement + i1 = self.get_current_ppk1() # In Amperes + i2 = self.get_current_ppk2() # In Amperes + + # LOGGING MEASUREMENT INFORMATION (Displaying current in mA) + log_msg = f"STEP {current_step}/{total_steps} | PPK1: V={v1:.4f}V, I={i1 * 1000:.3f}mA" + if self.ppk2: + log_msg += f" | PPK2: V={v2:.4f}V, I={i2 * 1000:.3f}mA" + print(log_msg) + + # Save data + v1_data.append(v1) + i1_data.append(i1) + v2_data_out.append(v2) + i2_data_out.append(i2) + + # Temperature measurement + temps.append(get_temperature() or 25.0) # Default to 25.0 if no sensor + + # Set voltage to 0 after test completion + self.set_voltage_ppk1(0) + if self.ppk2: + self.set_voltage_ppk2(0) + + print("------------------------------") + print("I-V Test Finished.") + + return v1_data, i1_data, v2_data_out, i2_data_out, temps \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/spice_exporter.py b/tools/smu-ppk2-gui/src/spice_exporter.py new file mode 100644 index 0000000..bce4481 --- /dev/null +++ b/tools/smu-ppk2-gui/src/spice_exporter.py @@ -0,0 +1,62 @@ +import csv +from datetime import datetime +import os + + +def export_to_spice(component_name, v_data, i_data, output_path, temp=None): + """ + Generates a SPICE netlist (.lib) containing a subcircuit (.SUBCKT) + that models the measured V-I characteristics using a Piecewise Linear (PWL) source. + + The model uses a Voltage-Controlled Current Source (G-source) controlled by V(1, 2). + + Args: + component_name (str): Name of the component. + v_data (list): List of measured voltage points (V). + i_data (list): List of measured current points (A). + output_path (str): Full path to save the .lib file. + temp (float, optional): Temperature data (unused in this model). + + Returns: + str: The path to the saved .lib file. + """ + + # Use the provided output_path to get the filename for the .lib directive + filename = os.path.basename(output_path) + + # Explicitly open the file with UTF-8 encoding + with open(output_path, 'w', encoding='utf-8') as f: + f.write( + f"* SPICE Library Model for {component_name} – Measured {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write("* Model generated by PPK2 SMU Export Tool\n\n") + + # Define the subcircuit name (ensure SPICE compatibility) + subckt_name = component_name.replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '') + + # .SUBCKT Directive (2 terminals: 1 and 2) + f.write(f".SUBCKT {subckt_name} 1 2\n") + f.write("* G_MEAS implements the measured I(V) characteristic using a PWL function.\n") + f.write("* G_MEAS: Node 1 is the positive terminal, Node 2 is the negative terminal.\n") + f.write("* Control voltage is V(1, 2).\n") + + # PWL G-source implementation: G <+out> <-out> VALUE={... PWL(V_control, V1, I1, V2, I2, ...)} + f.write("G_MEAS 1 2 VALUE={\n") + f.write(" + PWL(V(1, 2),\n") + + # Write the V-I pairs (V(control), I(output)) + for i, (v, current) in enumerate(zip(v_data, i_data)): + # Use '+' for continuation lines in SPICE + line_end = ',' if i < len(v_data) - 1 else '' + # SPICE PWL values must be formatted precisely (using E notation for robustness) + f.write(f" + {v:.6E}, {current:.9E}{line_end}\n") + + f.write(" + )\n") # Closing parenthesis for PWL + f.write(" }\n") # Closing brace for VALUE + + # .ENDS Directive + f.write(f".ENDS {subckt_name}\n\n") + + # Library definition for easy inclusion in SPICE simulators + f.write(f".lib {filename} {subckt_name}\n") + + return output_path \ No newline at end of file diff --git a/tools/smu-ppk2-gui/src/utils.py b/tools/smu-ppk2-gui/src/utils.py new file mode 100644 index 0000000..464e704 --- /dev/null +++ b/tools/smu-ppk2-gui/src/utils.py @@ -0,0 +1,31 @@ +#from w1thermsensor import W1ThermSensor +import time + +sensor = None +#try: +# sensor = W1ThermSensor() +#except Exception: + #pass # no sensor connected + +def get_temperature(): + """Return temperature in °C from DS18B20 sensor or None if not available.""" + #if sensor: + #return sensor.get_temperature() + return None + +def calculate_power(v, i): + """Calculate instantaneous power in Watts.""" + return abs(v * i) + +def check_limits(v, i_limit, component_params): + """Check voltage/current/power/temperature limits from YAML.""" + p = calculate_power(v, i_limit) + pd_max = component_params.get('pd_max', 0.5) + if p > pd_max: + return False, f"Power {p*1000:.1f} mW > Pd_max {pd_max*1000:.1f} mW!" + if v > component_params.get('max_v', 10): + return False, "Voltage too high!" + t = get_temperature() + if t and t > component_params.get('t_max', 80): + return False, f"Temperature {t:.1f}°C > limit!" + return True, "OK" \ No newline at end of file