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

+## 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.
+
+
+
+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.
+
+
+
+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 @@
+
\ 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