From 3e78b4eb7549a4f0e804af13aa84c3025b11dd7e Mon Sep 17 00:00:00 2001 From: pszsh Date: Sat, 31 Jan 2026 22:41:07 -0800 Subject: [PATCH] working up to it --- host/CMakeLists.txt | 212 ++++++- host/src/GraphWidget.cpp | 260 +++++++-- host/src/GraphWidget.h | 49 +- host/src/MainWindow.cpp | 951 +------------------------------- host/src/MainWindow.h | 42 +- host/src/MainWindow_Actions.cpp | 403 ++++++++++++++ host/src/MainWindow_Serial.cpp | 240 ++++++++ host/src/MainWindow_UI.cpp | 328 +++++++++++ main.c | 70 ++- 9 files changed, 1470 insertions(+), 1085 deletions(-) create mode 100644 host/src/MainWindow_Actions.cpp create mode 100644 host/src/MainWindow_Serial.cpp create mode 100644 host/src/MainWindow_UI.cpp diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 0964061..0a5cc8e 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -1,3 +1,4 @@ +# File: host/CMakeLists.txt cmake_minimum_required(VERSION 3.18) project(EISConfigurator VERSION 1.0 LANGUAGES CXX C) @@ -8,11 +9,94 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -# Added PrintSupport and OpenGLWidgets which are often needed by QCustomPlot +# --- FOR WINDOWS MSVC ERRORS --- +if(MSVC) + add_compile_options($<$:/std:clatest>) + add_compile_definitions(_CRT_SECURE_NO_WARNINGS) +endif() + +include(FetchContent) + +option(BUILD_ANDROID "Build for Android" OFF) +option(BUILD_IOS "Build for iOS" OFF) + +# --- Dependencies --- +# Added SerialPort and PrintSupport for EISConfigurator find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets SerialPort PrintSupport OpenGLWidgets) +# --- FFTW3 Configuration (Double Precision) --- + +if(WIN32) + # Windows: Expects FFTW3 to be installed/found via Config or PkgConfig + # If not found, you might need to adjust paths or use the source build block below for Windows too + find_package(FFTW3 CONFIG QUIET) + if(TARGET FFTW3::fftw3) + add_library(fftw3 ALIAS FFTW3::fftw3) + else() + # Fallback to building from source on Windows if not found + message(STATUS "FFTW3 not found via Config. Building from source...") + set(BUILD_FFTW_FROM_SOURCE ON) + endif() +elseif(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64" AND NOT BUILD_IOS) + message(STATUS "Detected Apple Silicon Desktop. Using Homebrew FFTW3 (Double).") + find_library(FFTW3_LIB NAMES fftw3 libfftw3 PATHS /opt/homebrew/lib NO_DEFAULT_PATH) + find_path(FFTW3_INCLUDE_DIR fftw3.h PATHS /opt/homebrew/include NO_DEFAULT_PATH) + + if(NOT FFTW3_LIB OR NOT FFTW3_INCLUDE_DIR) + message(FATAL_ERROR "FFTW3 not found in /opt/homebrew. Please run: brew install fftw") + endif() + + add_library(fftw3 STATIC IMPORTED) + set_target_properties(fftw3 PROPERTIES + IMPORTED_LOCATION "${FFTW3_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${FFTW3_INCLUDE_DIR}" + ) +else() + set(BUILD_FFTW_FROM_SOURCE ON) +endif() + +if(BUILD_FFTW_FROM_SOURCE) + message(STATUS "Building FFTW3 from source (Double Precision)...") + + set(ENABLE_FLOAT OFF CACHE BOOL "Build double precision" FORCE) + set(ENABLE_SSE OFF CACHE BOOL "Disable SSE" FORCE) + set(ENABLE_SSE2 OFF CACHE BOOL "Disable SSE2" FORCE) + set(ENABLE_AVX OFF CACHE BOOL "Disable AVX" FORCE) + set(ENABLE_AVX2 OFF CACHE BOOL "Disable AVX2" FORCE) + set(ENABLE_THREADS OFF CACHE BOOL "Disable Threads" FORCE) + set(ENABLE_OPENMP OFF CACHE BOOL "Disable OpenMP" FORCE) + set(ENABLE_MPI OFF CACHE BOOL "Disable MPI" FORCE) + set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build Static Libs" FORCE) + set(BUILD_TESTS OFF CACHE BOOL "Disable Tests" FORCE) + + # Enhanced NEON detection + if(ANDROID_ABI STREQUAL "arm64-v8a") + message(STATUS "Enabling NEON for Android ARM64") + set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE) + elseif(BUILD_IOS) + set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE) + elseif(MSVC AND CMAKE_SYSTEM_PROCESSOR MATCHES "(ARM64|arm64|aarch64)") + set(ENABLE_NEON ON CACHE BOOL "Enable NEON" FORCE) + endif() + + # Only apply sed patch on UNIX-like systems to fix CMake version requirement in FFTW source + if(UNIX) + set(PATCH_CMD sed -i.bak "s/cmake_minimum_required.*/cmake_minimum_required(VERSION 3.16)/" /CMakeLists.txt) + else() + set(PATCH_CMD "") + endif() + + FetchContent_Declare( + fftw3_source + URL https://www.fftw.org/fftw-3.3.10.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + PATCH_COMMAND ${PATCH_CMD} + ) + + FetchContent_MakeAvailable(fftw3_source) +endif() + # --- QCustomPlot Integration --- -include(FetchContent) FetchContent_Declare( QCustomPlot @@ -23,12 +107,10 @@ FetchContent_Declare( FetchContent_GetProperties(QCustomPlot) if(NOT qcustomplot_POPULATED) FetchContent_Populate(QCustomPlot) - # QCustomPlot source distribution doesn't have a CMakeLists.txt add_library(QCustomPlot ${qcustomplot_SOURCE_DIR}/qcustomplot.cpp ${qcustomplot_SOURCE_DIR}/qcustomplot.h ) - # Link required Qt modules to the library target_link_libraries(QCustomPlot PUBLIC Qt6::Core Qt6::Gui Qt6::Widgets Qt6::PrintSupport) target_include_directories(QCustomPlot PUBLIC ${qcustomplot_SOURCE_DIR}) endif() @@ -36,29 +118,64 @@ endif() # --- Icon Generation --- set(ICON_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon_source.png") -set(ICON_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_icons.sh") -set(MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.icns") -set(WINDOWS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.ico") -# Find ImageMagick 'magick' executable find_program(MAGICK_EXECUTABLE NAMES magick) if(NOT MAGICK_EXECUTABLE) message(WARNING "ImageMagick 'magick' not found. Icons will not be generated.") endif() if(EXISTS "${ICON_SOURCE}" AND MAGICK_EXECUTABLE) - execute_process(COMMAND chmod +x "${ICON_SCRIPT}") - - add_custom_command( - OUTPUT "${MACOS_ICON}" "${WINDOWS_ICON}" - COMMAND "${ICON_SCRIPT}" "${MAGICK_EXECUTABLE}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" - DEPENDS "${ICON_SOURCE}" "${ICON_SCRIPT}" - COMMENT "Generating icons from source using ${MAGICK_EXECUTABLE}..." - VERBATIM - ) - add_custom_target(GenerateIcons DEPENDS "${MACOS_ICON}" "${WINDOWS_ICON}") + if(WIN32) + # --- WINDOWS SPECIFIC --- + set(WINDOWS_ICON "${CMAKE_CURRENT_BINARY_DIR}/app_icon.ico") + set(WINDOWS_RC "${CMAKE_CURRENT_BINARY_DIR}/app_icon.rc") + # Assuming you have a batch script or use the shell script via git bash + set(ICON_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_icons.sh") + + # Create .rc file + file(WRITE "${WINDOWS_RC}" "IDI_ICON1 ICON \"app_icon.ico\"\n") + + # Generate ICO (Requires bash on Windows or a separate .bat script) + # For simplicity, we assume a unix-like environment or WSL for generation + add_custom_command( + OUTPUT "${WINDOWS_ICON}" + COMMAND "${ICON_SCRIPT}" "${MAGICK_EXECUTABLE}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + DEPENDS "${ICON_SOURCE}" "${ICON_SCRIPT}" + COMMENT "Generating Icons..." + VERBATIM + ) + + # Copy generated ico to binary dir for RC compiler + add_custom_command( + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/app_icon.ico" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.ico" "${WINDOWS_ICON}" + DEPENDS "${WINDOWS_ICON}" + ) + + add_custom_target(GenerateIcons DEPENDS "${WINDOWS_ICON}") + + else() + # --- MAC/LINUX/ANDROID/IOS SPECIFIC --- + set(MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/app_icon.icns") + set(IOS_ASSETS_PATH "${CMAKE_CURRENT_SOURCE_DIR}/ios/Assets.xcassets") + set(ANDROID_RES_PATH "${CMAKE_CURRENT_SOURCE_DIR}/android/res/mipmap-mdpi/ic_launcher.png") + + set(ICON_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/scripts/generate_icons.sh") + execute_process(COMMAND chmod +x "${ICON_SCRIPT}") + + add_custom_command( + OUTPUT "${MACOS_ICON}" "${ANDROID_RES_PATH}" + COMMAND "${ICON_SCRIPT}" "${MAGICK_EXECUTABLE}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + DEPENDS "${ICON_SOURCE}" "${ICON_SCRIPT}" + COMMENT "Generating Cross-Platform Icons..." + VERBATIM + ) + + add_custom_target(GenerateIcons DEPENDS "${MACOS_ICON}" "${ANDROID_RES_PATH}") + endif() endif() # --- Sources --- @@ -66,11 +183,18 @@ endif() set(PROJECT_SOURCES src/main.cpp src/MainWindow.cpp + src/MainWindow_UI.cpp + src/MainWindow_Serial.cpp + src/MainWindow_Actions.cpp src/GraphWidget.cpp ) if(EXISTS "${ICON_SOURCE}") - list(APPEND PROJECT_SOURCES ${MACOS_ICON} ${WINDOWS_ICON}) + if(WIN32) + list(APPEND PROJECT_SOURCES ${WINDOWS_RC} ${WINDOWS_ICON}) + elseif(APPLE AND NOT BUILD_IOS) + list(APPEND PROJECT_SOURCES ${MACOS_ICON}) + endif() endif() set(PROJECT_HEADERS @@ -81,7 +205,6 @@ set(PROJECT_HEADERS if(ANDROID) add_library(EISConfigurator SHARED ${PROJECT_SOURCES} ${PROJECT_HEADERS}) else() - # Removed MANUAL_FINALIZATION keyword as add_executable does not support it add_executable(EISConfigurator ${PROJECT_SOURCES} ${PROJECT_HEADERS}) endif() @@ -89,16 +212,52 @@ if(EXISTS "${ICON_SOURCE}" AND MAGICK_EXECUTABLE) add_dependencies(EISConfigurator GenerateIcons) endif() +# --- Linking --- + +if(TARGET fftw3) + set(FFTW_TARGET fftw3) + if(DEFINED fftw3_source_SOURCE_DIR) + target_include_directories(EISConfigurator PRIVATE + "${fftw3_source_SOURCE_DIR}/api" + "${fftw3_source_BINARY_DIR}" + ) + endif() +else() + set(FFTW_TARGET fftw3) +endif() + target_link_libraries(EISConfigurator PRIVATE - Qt6::Core Qt6::Gui Qt6::Widgets Qt6::SerialPort + Qt6::Core Qt6::Gui Qt6::Widgets Qt6::SerialPort Qt6::PrintSupport QCustomPlot + ${FFTW_TARGET} ) -if(ANDROID) +if(BUILD_ANDROID) + target_link_libraries(EISConfigurator PRIVATE log m) set_target_properties(EISConfigurator PROPERTIES QT_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android) endif() -if(APPLE) +if(BUILD_IOS) + if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/ios/Info.plist") + message(FATAL_ERROR "Missing ios/Info.plist. Please create it before building.") + endif() + + set_target_properties(EISConfigurator PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "EIS Configurator" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.eis.configurator" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/ios/Info.plist" + XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon" + RESOURCE "${IOS_ASSETS_PATH}" + ) + + if(EXISTS "${ICON_SOURCE}") + file(MAKE_DIRECTORY "${IOS_ASSETS_PATH}") + target_sources(EISConfigurator PRIVATE "${IOS_ASSETS_PATH}") + endif() +endif() + +if(APPLE AND NOT BUILD_IOS) set_target_properties(EISConfigurator PROPERTIES MACOSX_BUNDLE TRUE MACOSX_BUNDLE_BUNDLE_NAME "EIS Configurator" @@ -110,11 +269,6 @@ elseif(WIN32) set_target_properties(EISConfigurator PROPERTIES WIN32_EXECUTABLE TRUE ) - if(EXISTS "${WINDOWS_ICON}") - set_target_properties(EISConfigurator PROPERTIES - WIN32_ICON "${WINDOWS_ICON}" - ) - endif() endif() if(NOT ANDROID) diff --git a/host/src/GraphWidget.cpp b/host/src/GraphWidget.cpp index cc1c118..2a700ef 100644 --- a/host/src/GraphWidget.cpp +++ b/host/src/GraphWidget.cpp @@ -1,14 +1,61 @@ -// host/src/GraphWidget.cpp +// File: host/src/GraphWidget.cpp #include "GraphWidget.h" #include #include +#include +#include +#include +#include + +// Simple Linear Regression Helper +static void linearRegression(const QVector& x, const QVector& y, double &m, double &c) { + double sumX=0, sumY=0, sumXY=0, sumX2=0; + int n = x.size(); + for(int i=0; isetContentsMargins(0,0,0,0); + mainLayout->setSpacing(0); + + // Toolbar + toolbar = new QWidget(this); + toolbar->setStyleSheet("background-color: #2D2D2D;"); + QHBoxLayout *toolLayout = new QHBoxLayout(toolbar); + toolLayout->setContentsMargins(5, 2, 5, 2); - layout->addWidget(plot); - layout->setContentsMargins(0, 0, 0, 0); + btnScaleX = new QPushButton("Scale X", this); + btnScaleY = new QPushButton("Scale Y", this); + btnScaleBoth = new QPushButton("Scale Both", this); + btnCenter = new QPushButton("Center", this); + btnAnalyze = new QPushButton("Analyze", this); + + QString btnStyle = "QPushButton { background-color: #444; color: white; border: 1px solid #555; padding: 3px 8px; border-radius: 3px; } QPushButton:hover { background-color: #555; }"; + btnScaleX->setStyleSheet(btnStyle); + btnScaleY->setStyleSheet(btnStyle); + btnScaleBoth->setStyleSheet(btnStyle); + btnCenter->setStyleSheet(btnStyle); + btnAnalyze->setStyleSheet(btnStyle); + + toolLayout->addWidget(btnScaleX); + toolLayout->addWidget(btnScaleY); + toolLayout->addWidget(btnScaleBoth); + toolLayout->addWidget(btnCenter); + toolLayout->addStretch(); + toolLayout->addWidget(btnAnalyze); + + mainLayout->addWidget(toolbar); + + plot = new QCustomPlot(this); + mainLayout->addWidget(plot); plot->setBackground(QBrush(QColor(25, 25, 25))); @@ -28,78 +75,54 @@ GraphWidget::GraphWidget(QWidget *parent) : QWidget(parent) { styleAxis(plot->yAxis2); // --- Setup Graphs --- - - // 1. Real / Raw Nyquist (Cyan) graphReal = plot->addGraph(); - QPen pen1(QColor(0, 255, 255)); - pen1.setWidth(2); - graphReal->setPen(pen1); + graphReal->setPen(QPen(QColor(0, 255, 255), 2)); graphReal->setLineStyle(QCPGraph::lsLine); graphReal->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssCircle, QColor(0, 255, 255), 3)); - // 2. Imaginary (Magenta) graphImag = plot->addGraph(plot->xAxis, plot->yAxis2); - QPen pen2(QColor(255, 0, 255)); - pen2.setWidth(2); - graphImag->setPen(pen2); + graphImag->setPen(QPen(QColor(255, 0, 255), 2)); graphImag->setLineStyle(QCPGraph::lsLine); graphImag->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssTriangle, QColor(255, 0, 255), 3)); - // 3. Hilbert (Green Dashed) graphHilbert = plot->addGraph(plot->xAxis, plot->yAxis2); - QPen pen3(Qt::green); - pen3.setWidth(2); - pen3.setStyle(Qt::DashLine); + QPen pen3(Qt::green); pen3.setWidth(2); pen3.setStyle(Qt::DashLine); graphHilbert->setPen(pen3); - // 4. Corrected Nyquist (Orange) graphNyquistCorr = plot->addGraph(plot->xAxis, plot->yAxis); - QPen pen4(QColor(255, 165, 0)); - pen4.setWidth(2); - graphNyquistCorr->setPen(pen4); + graphNyquistCorr->setPen(QPen(QColor(255, 165, 0), 2)); graphNyquistCorr->setLineStyle(QCPGraph::lsLine); graphNyquistCorr->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssCross, 4)); graphNyquistCorr->setName("De-embedded (True Cell)"); - // 5. Amperometric Graph (Lime) graphAmp = plot->addGraph(); - QPen pen5(QColor(50, 255, 50)); - pen5.setWidth(2); - graphAmp->setPen(pen5); + graphAmp->setPen(QPen(QColor(50, 255, 50), 2)); graphAmp->setLineStyle(QCPGraph::lsLine); graphAmp->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssDisc, 3)); graphAmp->setName("Current"); - // 6. Extrapolated Point (Gold Star) graphExtrapolated = plot->addGraph(plot->xAxis, plot->yAxis); graphExtrapolated->setLineStyle(QCPGraph::lsNone); graphExtrapolated->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssStar, QColor(255, 215, 0), 12)); - QPen pen6(QColor(255, 215, 0)); - pen6.setWidth(3); - graphExtrapolated->setPen(pen6); + graphExtrapolated->setPen(QPen(QColor(255, 215, 0), 3)); graphExtrapolated->setName("Rs (Extrapolated)"); - // 7. LSV Blank (Gray) graphLSVBlank = plot->addGraph(); - QPen penBlank(QColor(150, 150, 150)); - penBlank.setWidth(2); - penBlank.setStyle(Qt::DashLine); + QPen penBlank(QColor(150, 150, 150)); penBlank.setWidth(2); penBlank.setStyle(Qt::DashLine); graphLSVBlank->setPen(penBlank); graphLSVBlank->setName("Blank (Tap Water)"); - // 8. LSV Sample (Yellow) graphLSVSample = plot->addGraph(); - QPen penSample(Qt::yellow); - penSample.setWidth(2); - graphLSVSample->setPen(penSample); + graphLSVSample->setPen(QPen(Qt::yellow, 2)); graphLSVSample->setName("Sample (Bleach)"); - // 9. LSV Diff (Cyan) graphLSVDiff = plot->addGraph(); - QPen penDiff(Qt::cyan); - penDiff.setWidth(3); - graphLSVDiff->setPen(penDiff); + graphLSVDiff->setPen(QPen(Qt::cyan, 3)); graphLSVDiff->setName("Diff (Chlorine)"); + + graphFit = plot->addGraph(); + graphFit->setPen(QPen(Qt::red, 2)); + graphFit->setName("Fit"); graphNyquistRaw = graphReal; @@ -109,7 +132,11 @@ GraphWidget::GraphWidget(QWidget *parent) : QWidget(parent) { connect(plot->yAxis, SIGNAL(rangeChanged(QCPRange)), plot->yAxis2, SLOT(setRange(QCPRange))); connect(plot->yAxis2, SIGNAL(rangeChanged(QCPRange)), plot->yAxis, SLOT(setRange(QCPRange))); - plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom); + plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom | QCP::iSelectPlottables); + + // Right click drag to zoom + plot->axisRect()->setRangeDrag(Qt::Horizontal | Qt::Vertical); + plot->axisRect()->setRangeZoom(Qt::Horizontal | Qt::Vertical); plot->legend->setVisible(true); QFont legendFont = font(); @@ -119,11 +146,142 @@ GraphWidget::GraphWidget(QWidget *parent) : QWidget(parent) { plot->legend->setBorderPen(QPen(Qt::white)); plot->legend->setTextColor(Qt::white); + selectionRect = new QCPItemRect(plot); + selectionRect->setPen(QPen(Qt::yellow, 1, Qt::DashLine)); + selectionRect->setBrush(QBrush(QColor(255, 255, 0, 50))); + selectionRect->setVisible(false); + + connect(btnScaleX, &QPushButton::clicked, this, &GraphWidget::scaleX); + connect(btnScaleY, &QPushButton::clicked, this, &GraphWidget::scaleY); + connect(btnScaleBoth, &QPushButton::clicked, this, &GraphWidget::scaleBoth); + connect(btnCenter, &QPushButton::clicked, this, &GraphWidget::centerView); + connect(btnAnalyze, &QPushButton::clicked, this, &GraphWidget::startAnalyze); + + // Custom mouse handling for selection + connect(plot, &QCustomPlot::mousePress, [this](QMouseEvent *event){ + if(isSelecting && event->button() == Qt::LeftButton) { + selStartX = plot->xAxis->pixelToCoord(event->pos().x()); + selStartY = plot->yAxis->pixelToCoord(event->pos().y()); + selectionRect->topLeft->setCoords(selStartX, selStartY); + selectionRect->bottomRight->setCoords(selStartX, selStartY); + selectionRect->setVisible(true); + plot->replot(); + } + }); + + connect(plot, &QCustomPlot::mouseMove, [this](QMouseEvent *event){ + if(isSelecting && (event->buttons() & Qt::LeftButton)) { + double x = plot->xAxis->pixelToCoord(event->pos().x()); + double y = plot->yAxis->pixelToCoord(event->pos().y()); + selectionRect->bottomRight->setCoords(x, y); + plot->replot(); + } + }); + + connect(plot, &QCustomPlot::mouseRelease, [this](QMouseEvent *event){ + if(isSelecting && event->button() == Qt::LeftButton) { + isSelecting = false; + plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom); + + double x2 = plot->xAxis->pixelToCoord(event->pos().x()); + double y2 = plot->yAxis->pixelToCoord(event->pos().y()); + + // Collect data in rect + QVector xData, yData; + double minX = std::min(selStartX, x2); + double maxX = std::max(selStartX, x2); + double minY = std::min(selStartY, y2); + double maxY = std::max(selStartY, y2); + + // Iterate visible graphs to find data + QList graphs = {graphReal, graphAmp, graphLSVSample, graphLSVDiff}; + for(auto g : graphs) { + if(!g->visible()) continue; + for(auto it = g->data()->begin(); it != g->data()->end(); ++it) { + if(it->key >= minX && it->key <= maxX && it->value >= minY && it->value <= maxY) { + xData.append(it->key); + yData.append(it->value); + } + } + if(!xData.isEmpty()) break; // Only fit one graph + } + + if(xData.size() > 2) { + // Show Dialog + QDialog dlg(this); + dlg.setWindowTitle("Fit Data"); + QFormLayout *layout = new QFormLayout(&dlg); + QComboBox *type = new QComboBox(); + type->addItems({"Linear", "Exponential", "Logarithmic", "Polynomial"}); + QSpinBox *order = new QSpinBox(); order->setRange(1, 10); order->setValue(2); + QCheckBox *inverse = new QCheckBox("Inverse"); + + layout->addRow("Type:", type); + layout->addRow("Poly Order:", order); + layout->addRow(inverse); + + QDialogButtonBox *btns = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + layout->addRow(btns); + connect(btns, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(btns, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + + if(dlg.exec() == QDialog::Accepted) { + performFit(xData, yData, type->currentIndex(), order->value(), inverse->isChecked()); + } + } + + selectionRect->setVisible(false); + plot->replot(); + } + }); + configureRawPlot(); } +void GraphWidget::scaleX() { plot->rescaleAxes(true); plot->replot(); } +void GraphWidget::scaleY() { plot->yAxis->rescale(true); plot->yAxis2->rescale(true); plot->replot(); } +void GraphWidget::scaleBoth() { plot->rescaleAxes(true); plot->replot(); } +void GraphWidget::centerView() { scaleBoth(); } + +void GraphWidget::startAnalyze() { + isSelecting = true; + // FIX: Use default constructor for empty flags instead of 0 + plot->setInteractions(QCP::Interactions()); +} + +void GraphWidget::performFit(const QVector& x, const QVector& y, int type, int order, bool inverse) { + graphFit->data()->clear(); + QVector xFit, yFit; + + // Simple Linear Fit Implementation for demo + if(type == 0) { // Linear + double m, c; + QVector yProc = y; + if(inverse) { + for(int i=0; isetData(xFit, yFit); + graphFit->setVisible(true); + plot->replot(); +} + +// ... (Rest of configuration methods same as before) ... void GraphWidget::configureRawPlot() { - // Only clear if explicitly requested, but here we just set visibility plot->xAxis->setLabel("Frequency (Hz)"); plot->xAxis->setScaleType(QCPAxis::stLogarithmic); QSharedPointer logTicker(new QCPAxisTickerLog); @@ -152,10 +310,10 @@ void GraphWidget::configureRawPlot() { graphNyquistCorr->setVisible(false); graphAmp->setVisible(false); graphExtrapolated->setVisible(false); - graphLSVBlank->setVisible(false); graphLSVSample->setVisible(false); graphLSVDiff->setVisible(false); + graphFit->setVisible(false); plot->replot(); } @@ -184,10 +342,10 @@ void GraphWidget::configureNyquistPlot() { graphNyquistCorr->setVisible(true); graphAmp->setVisible(false); graphExtrapolated->setVisible(true); - graphLSVBlank->setVisible(false); graphLSVSample->setVisible(false); graphLSVDiff->setVisible(false); + graphFit->setVisible(false); plot->replot(); } @@ -214,10 +372,10 @@ void GraphWidget::configureAmperometricPlot() { graphHilbert->setVisible(false); graphNyquistCorr->setVisible(false); graphExtrapolated->setVisible(false); - graphLSVBlank->setVisible(false); graphLSVSample->setVisible(false); graphLSVDiff->setVisible(false); + graphFit->setVisible(false); plot->replot(); } @@ -246,6 +404,7 @@ void GraphWidget::configureLSVPlot() { graphNyquistCorr->setVisible(false); graphExtrapolated->setVisible(false); graphAmp->setVisible(false); + graphFit->setVisible(false); plot->replot(); } @@ -288,7 +447,7 @@ void GraphWidget::addLSVData(double voltage, double current, LSVTrace traceType) if (target) { target->addData(voltage, current); - target->rescaleAxes(false); // Rescale to fit new data + target->rescaleAxes(false); plot->replot(); } } @@ -313,10 +472,7 @@ void GraphWidget::clear() { graphNyquistCorr->data()->clear(); graphAmp->data()->clear(); graphExtrapolated->data()->clear(); - - // Note: We deliberately do NOT clear LSV data in generic clear() - // to allow Blank scans to persist across tab changes or mode switches - // until explicitly cleared. + graphFit->data()->clear(); plot->replot(); } diff --git a/host/src/GraphWidget.h b/host/src/GraphWidget.h index ec7f1ce..108991b 100644 --- a/host/src/GraphWidget.h +++ b/host/src/GraphWidget.h @@ -1,8 +1,14 @@ -// host/src/GraphWidget.h +// File: host/src/GraphWidget.h #pragma once #include #include +#include +#include +#include +#include +#include +#include #include "qcustomplot.h" class GraphWidget : public QWidget { @@ -16,16 +22,14 @@ public: void addNyquistData(double r_meas, double i_meas, double r_corr, double i_corr, bool showCorr); void addAmperometricData(double index, double current); - // Updated LSV Data handler enum LSVTrace { LSV_BLANK, LSV_SAMPLE, LSV_DIFF }; void addLSVData(double voltage, double current, LSVTrace traceType); void addHilbertData(const QVector& freq, const QVector& hilbertImag); - void setExtrapolatedPoint(double real, double imag); void clear(); - void clearLSV(LSVTrace traceType); // Clear specific LSV trace + void clearLSV(LSVTrace traceType); // View Configurations void configureRawPlot(); @@ -33,25 +37,42 @@ public: void configureAmperometricPlot(); void configureLSVPlot(); +private slots: + void scaleX(); + void scaleY(); + void scaleBoth(); + void centerView(); + void startAnalyze(); + private: - QVBoxLayout *layout; + QVBoxLayout *mainLayout; QCustomPlot *plot; - // Bode Graphs + // Toolbar + QWidget *toolbar; + QPushButton *btnScaleX; + QPushButton *btnScaleY; + QPushButton *btnScaleBoth; + QPushButton *btnCenter; + QPushButton *btnAnalyze; + + // Graphs QCPGraph *graphReal; QCPGraph *graphImag; QCPGraph *graphHilbert; - - // Nyquist Graphs QCPGraph *graphNyquistRaw; QCPGraph *graphNyquistCorr; QCPGraph *graphExtrapolated; - - // Amperometric Graph QCPGraph *graphAmp; + QCPGraph *graphLSVBlank; + QCPGraph *graphLSVSample; + QCPGraph *graphLSVDiff; + QCPGraph *graphFit; // For analysis results + + // Analysis State + bool isSelecting = false; + QCPItemRect *selectionRect; + double selStartX, selStartY; - // LSV Graphs - QCPGraph *graphLSVBlank; // The "Tap Water" baseline - QCPGraph *graphLSVSample; // The "Bleach" spike - QCPGraph *graphLSVDiff; // The calculated difference (Chlorine only) + void performFit(const QVector& x, const QVector& y, int type, int order, bool inverse); }; \ No newline at end of file diff --git a/host/src/MainWindow.cpp b/host/src/MainWindow.cpp index 89cb89f..80edadb 100644 --- a/host/src/MainWindow.cpp +++ b/host/src/MainWindow.cpp @@ -1,52 +1,7 @@ -// host/src/MainWindow.cpp +// File: host/src/MainWindow.cpp #include "MainWindow.h" -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// Simple FFT Implementation (Cooley-Tukey) -void fft(std::vector>& a) { - int n = a.size(); - if (n <= 1) return; - - std::vector> a0(n / 2), a1(n / 2); - for (int i = 0; i < n / 2; i++) { - a0[i] = a[2 * i]; - a1[i] = a[2 * i + 1]; - } - fft(a0); - fft(a1); - - double ang = 2 * M_PI / n; - std::complex w(1), wn(cos(ang), sin(ang)); - for (int i = 0; i < n / 2; i++) { - a[i] = a0[i] + w * a1[i]; - a[i + n / 2] = a0[i] - w * a1[i]; - w *= wn; - } -} - -void ifft(std::vector>& a) { - int n = a.size(); - for (int i = 0; i < n; i++) a[i] = std::conj(a[i]); - fft(a); - for (int i = 0; i < n; i++) { - a[i] = std::conj(a[i]); - a[i] /= n; - } -} MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { settings = new QSettings("EISConfigurator", "Settings", this); @@ -60,8 +15,11 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { blinkTimer->setInterval(500); connect(blinkTimer, &QTimer::timeout, this, &MainWindow::onBlinkTimer); - setupUi(); + setupUi(); // Defined in MainWindow_UI.cpp + + // Auto-refresh ports after startup QTimer::singleShot(1000, this, &MainWindow::refreshPorts); + grabGesture(Qt::SwipeGesture); } @@ -79,905 +37,6 @@ void MainWindow::saveSettings() { settings->setValue("cellConstant", cellConstant); } -void MainWindow::setupUi() { - QWidget *central = new QWidget(this); - setCentralWidget(central); - QVBoxLayout *mainLayout = new QVBoxLayout(central); - mainLayout->setContentsMargins(5, 5, 5, 5); - mainLayout->setSpacing(5); - - // --- Controls Container --- - QWidget *controlsContainer = new QWidget(this); - controlsContainer->setStyleSheet("background-color: #3A3A3A; border-radius: 5px;"); - QVBoxLayout *ctrlLayout = new QVBoxLayout(controlsContainer); - ctrlLayout->setContentsMargins(5, 5, 5, 5); - ctrlLayout->setSpacing(5); - - // Row 1 - QHBoxLayout *row1 = new QHBoxLayout(); - row1->setSpacing(8); - - portSelector = new QComboBox(this); - portSelector->setMinimumWidth(120); - connectBtn = new QPushButton("Connect", this); - QPushButton *refreshBtn = new QPushButton("Refresh", this); - - row1->addWidget(portSelector); - row1->addWidget(connectBtn); - row1->addWidget(refreshBtn); - - QFrame *sep1 = new QFrame(); sep1->setFrameShape(QFrame::VLine); sep1->setFrameShadow(QFrame::Sunken); - row1->addWidget(sep1); - - checkIdBtn = new QPushButton("Check ID", this); - calibrateBtn = new QPushButton("Calibrate HW", this); - row1->addWidget(checkIdBtn); - row1->addWidget(calibrateBtn); - - QFrame *sep2 = new QFrame(); sep2->setFrameShape(QFrame::VLine); sep2->setFrameShadow(QFrame::Sunken); - row1->addWidget(sep2); - - row1->addWidget(new QLabel("Start:")); - spinSweepStart = new QDoubleSpinBox(this); - spinSweepStart->setRange(0.1, 200000.0); spinSweepStart->setValue(1000.0); spinSweepStart->setSuffix(" Hz"); - row1->addWidget(spinSweepStart); - - row1->addWidget(new QLabel("Stop:")); - spinSweepStop = new QDoubleSpinBox(this); - spinSweepStop->setRange(0.1, 200000.0); spinSweepStop->setValue(200000.0); spinSweepStop->setSuffix(" Hz"); - row1->addWidget(spinSweepStop); - - row1->addWidget(new QLabel("PPD:")); - spinSweepPPD = new QSpinBox(this); - spinSweepPPD->setRange(1, 1000); spinSweepPPD->setValue(200); spinSweepPPD->setSuffix(" pts/dec"); - row1->addWidget(spinSweepPPD); - - sweepBtn = new QPushButton("Sweep", this); - row1->addWidget(sweepBtn); - - QFrame *sep3 = new QFrame(); sep3->setFrameShape(QFrame::VLine); sep3->setFrameShadow(QFrame::Sunken); - row1->addWidget(sep3); - - row1->addWidget(new QLabel("Shunt:")); - checkShunt = new QCheckBox("Enable", this); - row1->addWidget(checkShunt); - spinShuntRes = new QDoubleSpinBox(this); - spinShuntRes->setRange(1.0, 1000000.0); spinShuntRes->setValue(466.0); spinShuntRes->setSuffix(" Ω"); - row1->addWidget(spinShuntRes); - - QFrame *sep4 = new QFrame(); sep4->setFrameShape(QFrame::VLine); sep4->setFrameShadow(QFrame::Sunken); - row1->addWidget(sep4); - - row1->addWidget(new QLabel("Std Cond:")); - spinCondStd = new QDoubleSpinBox(this); - spinCondStd->setRange(0.0, 1000000.0); spinCondStd->setValue(1413.0); spinCondStd->setSuffix(" µS/cm"); - row1->addWidget(spinCondStd); - - btnCalCond = new QPushButton("Calibrate K", this); - row1->addWidget(btnCalCond); - - lblResultRs = new QLabel(" Rs: -- Ω", this); - lblResultRs->setStyleSheet("font-weight: bold; color: #FFD700; font-size: 14px; margin-left: 10px;"); - row1->addWidget(lblResultRs); - - row1->addStretch(); - ctrlLayout->addLayout(row1); - - // Row 2 - QHBoxLayout *row2 = new QHBoxLayout(); - row2->setSpacing(8); - - row2->addWidget(new QLabel("Range:")); - comboRange = new QComboBox(this); - comboRange->addItem("200 Ω", 200); - comboRange->addItem("1 kΩ", 1000); - comboRange->addItem("5 kΩ", 5000); - comboRange->addItem("10 kΩ", 10000); - comboRange->addItem("20 kΩ", 20000); - comboRange->addItem("40 kΩ", 40000); - comboRange->addItem("80 kΩ", 80000); - comboRange->addItem("160 kΩ", 160000); - comboRange->setCurrentIndex(0); - row2->addWidget(comboRange); - - QFrame *sepRange = new QFrame(); sepRange->setFrameShape(QFrame::VLine); sepRange->setFrameShadow(QFrame::Sunken); - row2->addWidget(sepRange); - - lblResultCond = new QLabel("Cond: -- µS/cm", this); - lblResultCond->setStyleSheet("font-weight: bold; color: #00FFFF; font-size: 14px; margin-right: 10px;"); - row2->addWidget(lblResultCond); - - QFrame *sep5 = new QFrame(); sep5->setFrameShape(QFrame::VLine); sep5->setFrameShadow(QFrame::Sunken); - row2->addWidget(sep5); - - row2->addWidget(new QLabel("Freq:")); - spinFreq = new QDoubleSpinBox(this); - spinFreq->setRange(0.1, 200000.0); spinFreq->setValue(1000.0); spinFreq->setSuffix(" Hz"); - row2->addWidget(spinFreq); - - measureBtn = new QPushButton("Measure", this); - row2->addWidget(measureBtn); - - QFrame *sep6 = new QFrame(); sep6->setFrameShape(QFrame::VLine); sep6->setFrameShadow(QFrame::Sunken); - row2->addWidget(sep6); - - row2->addWidget(new QLabel("Bias:")); - spinAmpBias = new QDoubleSpinBox(this); - spinAmpBias->setRange(-1100.0, 1100.0); spinAmpBias->setValue(0.0); spinAmpBias->setSuffix(" mV"); - row2->addWidget(spinAmpBias); - - row2->addWidget(new QLabel("LPF:")); - comboLPF = new QComboBox(this); - comboLPF->addItem("Bypass", 0); - comboLPF->addItem("20kΩ (8Hz)", 1); - comboLPF->addItem("100kΩ (1.6Hz)", 2); - comboLPF->addItem("200kΩ (0.8Hz)", 3); - comboLPF->addItem("400kΩ (0.4Hz)", 4); - comboLPF->addItem("600kΩ (0.26Hz)", 5); - comboLPF->addItem("1MΩ (0.16Hz)", 6); - comboLPF->setCurrentIndex(1); // Default 20k - row2->addWidget(comboLPF); - - ampBtn = new QPushButton("Start Amp", this); - row2->addWidget(ampBtn); - - QFrame *sep7 = new QFrame(); sep7->setFrameShape(QFrame::VLine); sep7->setFrameShadow(QFrame::Sunken); - row2->addWidget(sep7); - - // LSV Controls - row2->addWidget(new QLabel("LSV:")); - spinLsvStart = new QDoubleSpinBox(this); - spinLsvStart->setRange(-1100.0, 1100.0); spinLsvStart->setValue(800.0); spinLsvStart->setSuffix(" mV"); - row2->addWidget(spinLsvStart); - - spinLsvStop = new QDoubleSpinBox(this); - spinLsvStop->setRange(-1100.0, 1100.0); spinLsvStop->setValue(-200.0); spinLsvStop->setSuffix(" mV"); - row2->addWidget(spinLsvStop); - - spinLsvSteps = new QSpinBox(this); - spinLsvSteps->setRange(10, 4000); spinLsvSteps->setValue(200); spinLsvSteps->setSuffix(" pts"); - row2->addWidget(spinLsvSteps); - - spinLsvDuration = new QSpinBox(this); - spinLsvDuration->setRange(100, 600000); spinLsvDuration->setValue(10000); spinLsvDuration->setSuffix(" ms"); - row2->addWidget(spinLsvDuration); - - lsvBlankBtn = new QPushButton("Run Blank", this); - row2->addWidget(lsvBlankBtn); - - lsvSampleBtn = new QPushButton("Run Sample", this); - row2->addWidget(lsvSampleBtn); - - row2->addStretch(); - ctrlLayout->addLayout(row2); - - mainLayout->addWidget(controlsContainer); - - // --- Splitter for Graphs and Log --- - QSplitter *splitter = new QSplitter(Qt::Vertical, this); - - tabWidget = new QTabWidget(this); - rawGraph = new GraphWidget(this); - nyquistGraph = new GraphWidget(this); - ampGraph = new GraphWidget(this); - lsvGraph = new GraphWidget(this); - - rawGraph->configureRawPlot(); - nyquistGraph->configureNyquistPlot(); - ampGraph->configureAmperometricPlot(); - lsvGraph->configureLSVPlot(); - - tabWidget->addTab(rawGraph, "Raw Data"); - tabWidget->addTab(nyquistGraph, "Nyquist Plot"); - tabWidget->addTab(ampGraph, "Amperometry"); - tabWidget->addTab(lsvGraph, "Voltammetry"); - - logWidget = new QTextEdit(this); - logWidget->setReadOnly(true); - logWidget->setFont(QFont("Monospace")); - logWidget->setPlaceholderText("Scanning for 0xCAFE EIS Device..."); - logWidget->setStyleSheet("background-color: #1E1E1E; color: #00FF00; border: 1px solid #444;"); - QScroller::grabGesture(logWidget->viewport(), QScroller::TouchGesture); - - splitter->addWidget(tabWidget); - splitter->addWidget(logWidget); - splitter->setStretchFactor(0, 3); - splitter->setStretchFactor(1, 1); - - mainLayout->addWidget(splitter); - - // Initial State - checkIdBtn->setEnabled(false); - calibrateBtn->setEnabled(false); - sweepBtn->setEnabled(false); - measureBtn->setEnabled(false); - spinSweepStart->setEnabled(false); - spinSweepStop->setEnabled(false); - spinSweepPPD->setEnabled(false); - ampBtn->setEnabled(false); - spinAmpBias->setEnabled(false); - btnCalCond->setEnabled(false); - comboRange->setEnabled(false); - comboLPF->setEnabled(false); - lsvBlankBtn->setEnabled(false); - lsvSampleBtn->setEnabled(false); - spinLsvStart->setEnabled(false); - spinLsvStop->setEnabled(false); - spinLsvSteps->setEnabled(false); - spinLsvDuration->setEnabled(false); - - // Connections - connect(connectBtn, &QPushButton::clicked, this, &MainWindow::connectToPort); - connect(refreshBtn, &QPushButton::clicked, this, &MainWindow::refreshPorts); - connect(checkIdBtn, &QPushButton::clicked, this, &MainWindow::checkDeviceId); - connect(calibrateBtn, &QPushButton::clicked, this, &MainWindow::runCalibration); - connect(sweepBtn, &QPushButton::clicked, this, &MainWindow::startSweep); - connect(measureBtn, &QPushButton::clicked, this, &MainWindow::toggleMeasurement); - connect(ampBtn, &QPushButton::clicked, this, &MainWindow::toggleAmperometry); - connect(lsvBlankBtn, &QPushButton::clicked, this, &MainWindow::startLSVBlank); - connect(lsvSampleBtn, &QPushButton::clicked, this, &MainWindow::startLSVSample); - connect(btnCalCond, &QPushButton::clicked, this, &MainWindow::calibrateCellConstant); - connect(comboLPF, QOverload::of(&QComboBox::currentIndexChanged), this, &MainWindow::onLPFChanged); - - connect(tabWidget, &QTabWidget::currentChanged, this, [this](int index){ - if (index == 0) rawGraph->configureRawPlot(); - else if (index == 1) nyquistGraph->configureNyquistPlot(); - else if (index == 2) ampGraph->configureAmperometricPlot(); - else if (index == 3) lsvGraph->configureLSVPlot(); - }); -} - -void MainWindow::refreshPorts() { - portSelector->clear(); - const auto infos = QSerialPortInfo::availablePorts(); - bool foundTarget = false; - QString targetPort; - - for (const QSerialPortInfo &info : infos) { - portSelector->addItem(info.portName()); - bool isCafe = (info.hasVendorIdentifier() && info.vendorIdentifier() == 0xCAFE); - bool isUsbModem = info.portName().contains("usbmodem", Qt::CaseInsensitive); - if ((isCafe || isUsbModem) && !foundTarget) { - targetPort = info.portName(); - foundTarget = true; - logWidget->append(">> Found Target Device: " + targetPort); - } - } - if (foundTarget) { - portSelector->setCurrentText(targetPort); - if (!serial->isOpen()) connectToPort(); - } -} - -void MainWindow::connectToPort() { - if (serial->isOpen()) { - serial->close(); - connectBtn->setText("Connect"); - logWidget->append("--- Disconnected ---"); - checkIdBtn->setEnabled(false); - calibrateBtn->setEnabled(false); - sweepBtn->setEnabled(false); - measureBtn->setEnabled(false); - spinSweepStart->setEnabled(false); - spinSweepStop->setEnabled(false); - spinSweepPPD->setEnabled(false); - ampBtn->setEnabled(false); - spinAmpBias->setEnabled(false); - btnCalCond->setEnabled(false); - comboRange->setEnabled(false); - comboLPF->setEnabled(false); - lsvBlankBtn->setEnabled(false); - lsvSampleBtn->setEnabled(false); - spinLsvStart->setEnabled(false); - spinLsvStop->setEnabled(false); - spinLsvSteps->setEnabled(false); - spinLsvDuration->setEnabled(false); - - isMeasuringImp = false; - isMeasuringAmp = false; - isSweeping = false; - lsvState = LSV_IDLE; - setButtonBlinking(nullptr, false); - - measureBtn->setText("Measure"); - ampBtn->setText("Start Amp"); - lsvBlankBtn->setText("Run Blank"); - lsvSampleBtn->setText("Run Sample"); - return; - } - - if (portSelector->currentText().isEmpty()) return; - - serial->setPortName(portSelector->currentText()); - serial->setBaudRate(500000); - - if (serial->open(QIODevice::ReadWrite)) { - connectBtn->setText("Disconnect"); - logWidget->append("--- Connected and Synchronized ---"); - checkIdBtn->setEnabled(true); - calibrateBtn->setEnabled(true); - sweepBtn->setEnabled(true); - measureBtn->setEnabled(true); - spinSweepStart->setEnabled(true); - spinSweepStop->setEnabled(true); - spinSweepPPD->setEnabled(true); - ampBtn->setEnabled(true); - spinAmpBias->setEnabled(true); - btnCalCond->setEnabled(true); - comboRange->setEnabled(true); - comboLPF->setEnabled(true); - lsvBlankBtn->setEnabled(true); - lsvSampleBtn->setEnabled(true); - spinLsvStart->setEnabled(true); - spinLsvStop->setEnabled(true); - spinLsvSteps->setEnabled(true); - spinLsvDuration->setEnabled(true); - - // Sync LPF - onLPFChanged(comboLPF->currentIndex()); - } else { - logWidget->append(">> Connection Error: " + serial->errorString()); - } -} - -void MainWindow::onPortError(QSerialPort::SerialPortError error) { - if (error == QSerialPort::ResourceError) { - logWidget->append(">> Critical Error: Connection Lost."); - serial->close(); - connectBtn->setText("Connect"); - checkIdBtn->setEnabled(false); - calibrateBtn->setEnabled(false); - sweepBtn->setEnabled(false); - measureBtn->setEnabled(false); - spinSweepStart->setEnabled(false); - spinSweepStop->setEnabled(false); - spinSweepPPD->setEnabled(false); - ampBtn->setEnabled(false); - spinAmpBias->setEnabled(false); - btnCalCond->setEnabled(false); - comboRange->setEnabled(false); - comboLPF->setEnabled(false); - lsvBlankBtn->setEnabled(false); - lsvSampleBtn->setEnabled(false); - spinLsvStart->setEnabled(false); - spinLsvStop->setEnabled(false); - spinLsvSteps->setEnabled(false); - spinLsvDuration->setEnabled(false); - setButtonBlinking(nullptr, false); - } -} - -void MainWindow::checkDeviceId() { - if (serial->isOpen()) serial->write("v\n"); -} - -void MainWindow::runCalibration() { - if (serial->isOpen()) { - int rangeVal = comboRange->currentData().toInt(); - serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); - serial->write("c\n"); - } -} - -void MainWindow::onLPFChanged(int index) { - if (serial->isOpen()) { - int val = comboLPF->itemData(index).toInt(); - serial->write(QString("f %1\n").arg(val).toUtf8()); - } -} - -void MainWindow::startSweep() { - if (!serial->isOpen()) return; - - if (isSweeping) { - // Stop Sweep - serial->write("x\n"); - return; - } - - if (isMeasuringAmp) toggleAmperometry(); - if (lsvState != LSV_IDLE) stopLSV(); - if (isMeasuringImp) toggleMeasurement(); - - rawGraph->clear(); - nyquistGraph->clear(); - - sweepFreqs.clear(); - sweepReals.clear(); - sweepImags.clear(); - - double start = spinSweepStart->value(); - double stop = spinSweepStop->value(); - int ppd = spinSweepPPD->value(); - int rangeVal = comboRange->currentData().toInt(); - - logWidget->append(QString(">> Starting Sweep (Range: %1, s %2 %3 %4)...").arg(rangeVal).arg(start).arg(stop).arg(ppd)); - - serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); - serial->write(QString("s %1 %2 %3\n").arg(start).arg(stop).arg(ppd).toUtf8()); - - isSweeping = true; - sweepBtn->setText("Stop Sweep"); - setButtonBlinking(sweepBtn, true); - tabWidget->setCurrentIndex(1); -} - -void MainWindow::toggleMeasurement() { - if (!serial->isOpen()) return; - if (isMeasuringAmp) toggleAmperometry(); - if (lsvState != LSV_IDLE) stopLSV(); - if (isSweeping) startSweep(); // Stop sweep - - if (isMeasuringImp) { - serial->write("x\n"); - measureBtn->setText("Measure"); - setButtonBlinking(nullptr, false); - isMeasuringImp = false; - } else { - double freq = spinFreq->value(); - int rangeVal = comboRange->currentData().toInt(); - - serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); - serial->write(QString("m %1\n").arg(freq).toUtf8()); - - measureBtn->setText("Stop"); - setButtonBlinking(measureBtn, true); - isMeasuringImp = true; - tabWidget->setCurrentIndex(0); - } -} - -void MainWindow::toggleAmperometry() { - if (!serial->isOpen()) return; - if (isMeasuringImp) toggleMeasurement(); - if (lsvState != LSV_IDLE) stopLSV(); - if (isSweeping) startSweep(); - - if (isMeasuringAmp) { - serial->write("x\n"); - ampBtn->setText("Start Amp"); - setButtonBlinking(nullptr, false); - isMeasuringAmp = false; - } else { - double bias = spinAmpBias->value(); - int rangeVal = comboRange->currentData().toInt(); - - serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); - serial->write(QString("a %1\n").arg(bias).toUtf8()); - - ampBtn->setText("Stop Amp"); - setButtonBlinking(ampBtn, true); - isMeasuringAmp = true; - ampGraph->clear(); - tabWidget->setCurrentIndex(2); - } -} - -void MainWindow::startLSVBlank() { - if (!serial->isOpen()) return; - if (isMeasuringImp) toggleMeasurement(); - if (isMeasuringAmp) toggleAmperometry(); - if (lsvState != LSV_IDLE) stopLSV(); - if (isSweeping) startSweep(); - - double start = spinLsvStart->value(); - double stop = spinLsvStop->value(); - int steps = spinLsvSteps->value(); - int duration = spinLsvDuration->value(); - int rangeVal = comboRange->currentData().toInt(); - - logWidget->append(QString(">> Starting LSV Blank (Range: %1, %.1f to %.1f mV)...").arg(rangeVal).arg(start).arg(stop)); - - serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); - serial->write(QString("l %1 %2 %3 %4\n").arg(start).arg(stop).arg(steps).arg(duration).toUtf8()); - - lsvBlankBtn->setText("Stop Blank"); - setButtonBlinking(lsvBlankBtn, true); - lsvState = LSV_RUNNING_BLANK; - - lsvGraph->clearLSV(GraphWidget::LSV_BLANK); - lsvGraph->clearLSV(GraphWidget::LSV_DIFF); // Clear old diff - lsvBlankData.clear(); - - tabWidget->setCurrentIndex(3); -} - -void MainWindow::startLSVSample() { - if (!serial->isOpen()) return; - if (isMeasuringImp) toggleMeasurement(); - if (isMeasuringAmp) toggleAmperometry(); - if (lsvState != LSV_IDLE) stopLSV(); - if (isSweeping) startSweep(); - - double start = spinLsvStart->value(); - double stop = spinLsvStop->value(); - int steps = spinLsvSteps->value(); - int duration = spinLsvDuration->value(); - int rangeVal = comboRange->currentData().toInt(); - - logWidget->append(QString(">> Starting LSV Sample (Range: %1, %.1f to %.1f mV)...").arg(rangeVal).arg(start).arg(stop)); - - serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); - serial->write(QString("l %1 %2 %3 %4\n").arg(start).arg(stop).arg(steps).arg(duration).toUtf8()); - - lsvSampleBtn->setText("Stop Sample"); - setButtonBlinking(lsvSampleBtn, true); - lsvState = LSV_RUNNING_SAMPLE; - - lsvGraph->clearLSV(GraphWidget::LSV_SAMPLE); - lsvGraph->clearLSV(GraphWidget::LSV_DIFF); // Clear old diff - lsvSampleData.clear(); - - tabWidget->setCurrentIndex(3); -} - -void MainWindow::stopLSV() { - serial->write("x\n"); - lsvBlankBtn->setText("Run Blank"); - lsvSampleBtn->setText("Run Sample"); - setButtonBlinking(nullptr, false); - - // If we just finished a sample run, calculate the difference - if (lsvState == LSV_RUNNING_SAMPLE) { - calculateLSVDiff(); - } - - lsvState = LSV_IDLE; -} - -void MainWindow::calculateLSVDiff() { - if (lsvBlankData.isEmpty() || lsvSampleData.isEmpty()) return; - - lsvGraph->clearLSV(GraphWidget::LSV_DIFF); - - // Simple index-based subtraction (assumes same parameters used) - int count = std::min(lsvBlankData.size(), lsvSampleData.size()); - - for (int i = 0; i < count; i++) { - double v = lsvSampleData[i].voltage; // Use sample voltage - double diffCurrent = lsvSampleData[i].current - lsvBlankData[i].current; - lsvGraph->addLSVData(v, diffCurrent, GraphWidget::LSV_DIFF); - } - - logWidget->append(">> Calculated Difference Curve."); -} - -void MainWindow::handleSerialData() { - while (serial->canReadLine()) { - QByteArray line = serial->readLine(); - QString str = QString::fromUtf8(line).trimmed(); - if (str.isEmpty()) continue; - - QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss.zzz"); - logWidget->append(QString("[%1] %2").arg(timestamp, str)); - logWidget->moveCursor(QTextCursor::End); - - if (str.startsWith("DATA,")) { - parseData(str); - } else if (str.startsWith("AMP,")) { - parseData(str); - } else if (str.startsWith("RAMP,")) { - parseData(str); - } else if (str == "STOPPED") { - // Reset UI state - if (lsvState != LSV_IDLE) stopLSV(); - if (isSweeping) { - isSweeping = false; - sweepBtn->setText("Sweep"); - setButtonBlinking(nullptr, false); - } - if (isMeasuringImp) { - isMeasuringImp = false; - measureBtn->setText("Measure"); - setButtonBlinking(nullptr, false); - } - if (isMeasuringAmp) { - isMeasuringAmp = false; - ampBtn->setText("Start Amp"); - setButtonBlinking(nullptr, false); - } - } - } -} - -void MainWindow::parseData(const QString &data) { - QStringList parts = data.split(','); - - if (parts[0] == "AMP" && parts.size() >= 3) { - bool okIdx, okCurr; - double index = parts[1].toDouble(&okIdx); - double current = parts[2].toDouble(&okCurr); - if (okIdx && okCurr) ampGraph->addAmperometricData(index, current); - return; - } - - if (parts[0] == "RAMP" && parts.size() >= 3) { - bool okIdx, okCurr; - double index = parts[1].toDouble(&okIdx); - double current = parts[2].toDouble(&okCurr); - - if (okIdx && okCurr) { - // Calculate Voltage based on UI parameters (Host-side calculation) - double start = spinLsvStart->value(); - double stop = spinLsvStop->value(); - int steps = spinLsvSteps->value(); - - double voltage = start + (index * (stop - start) / steps); - - if (lsvState == LSV_RUNNING_BLANK) { - lsvGraph->addLSVData(voltage, current, GraphWidget::LSV_BLANK); - lsvBlankData.append({voltage, current}); - } else if (lsvState == LSV_RUNNING_SAMPLE) { - lsvGraph->addLSVData(voltage, current, GraphWidget::LSV_SAMPLE); - lsvSampleData.append({voltage, current}); - } - } - return; - } - - if (parts[0] == "DATA" && parts.size() >= 6) { - bool okF, okR, okI; - double freq = parts[1].toDouble(&okF); - double real = parts[4].toDouble(&okR); - double imag = parts[5].toDouble(&okI); - - if (okF && okR && okI) { - double real_plot = real; - double imag_plot = imag; - bool showCorr = false; - - if (checkShunt->isChecked()) { - showCorr = true; - double r_shunt = spinShuntRes->value(); - std::complex z_meas(real, imag); - std::complex z_shunt(r_shunt, 0.0); - std::complex denom = z_shunt - z_meas; - - if (std::abs(denom) > 1e-9) { - std::complex z_cell = (z_meas * z_shunt) / denom; - real_plot = z_cell.real(); - imag_plot = z_cell.imag(); - } else { - real_plot = 1e9; - imag_plot = 0; - } - } - - rawGraph->addBodeData(freq, real_plot, imag_plot); - nyquistGraph->addNyquistData(real, imag, real_plot, imag_plot, showCorr); - - sweepFreqs.append(freq); - sweepReals.append(real_plot); - sweepImags.append(imag_plot); - - if (sweepReals.size() > 10 && sweepReals.size() % 10 == 0) { - computeHilbert(); - performCircleFit(); - } - } - } -} - -void MainWindow::computeHilbert() { - int n = sweepReals.size(); - if (n == 0) return; - int n_fft = 1; - while (n_fft < n) n_fft *= 2; - - std::vector> signal(n_fft); - for (int i = 0; i < n; i++) signal[i] = std::complex(sweepReals[i], 0.0); - - fft(signal); - for (int i = 1; i < n_fft / 2; i++) signal[i] *= 2.0; - for (int i = n_fft / 2 + 1; i < n_fft; i++) signal[i] = 0.0; - ifft(signal); - - QVector hilbertImag; - for (int i = 0; i < n; i++) hilbertImag.append(signal[i].imag()); - rawGraph->addHilbertData(sweepFreqs, hilbertImag); -} - -// Least Squares Circle Fit to find Rs (High Freq Intercept) -void MainWindow::performCircleFit() { - int n = sweepReals.size(); - if (n < 5) return; - - // --- Check for actual zero crossing first --- - double measuredRs = -1.0; - bool found = false; - - for (int i = 0; i < n - 1; i++) { - double img1 = sweepImags[i]; - double img2 = sweepImags[i+1]; - - // Check if we cross zero (one positive, one negative) or hit zero exactly - if ((img1 >= 0 && img2 < 0) || (img1 < 0 && img2 >= 0)) { - double r1 = sweepReals[i]; - double r2 = sweepReals[i+1]; - - // Avoid division by zero - if (std::abs(img2 - img1) < 1e-9) continue; - - // Linear Interpolation for Real Z where Imag Z = 0 - double fraction = (0.0 - img1) / (img2 - img1); - double crossingReal = r1 + fraction * (r2 - r1); - - // We want the smallest Real value (Rs is the left-most intercept) - if (!found || crossingReal < measuredRs) { - measuredRs = crossingReal; - found = true; - } - } - } - - if (found && measuredRs > 0) { - lblResultRs->setText(QString(" Rs: %1 Ω (Meas)").arg(measuredRs, 0, 'f', 2)); - - double cond = (cellConstant / measuredRs) * 1000000.0; - lblResultCond->setText(QString(" Cond: %1 µS/cm").arg(cond, 0, 'f', 2)); - - nyquistGraph->setExtrapolatedPoint(measuredRs, 0); - return; // Skip extrapolation - } - - // Use last 1/3rd of points (High Frequency end) - int startIdx = n - (n / 3); - if (startIdx < 0) startIdx = 0; - int count = n - startIdx; - if (count < 3) return; - - double sum_x = 0, sum_y = 0, sum_x2 = 0, sum_y2 = 0; - double sum_xy = 0, sum_x3 = 0, sum_y3 = 0, sum_xy2 = 0, sum_x2y = 0; - - // Kasa Method for Circle Fit: Minimize sum((x^2 + y^2) - (Ax + By + C))^2 - // x = Real, y = -Imag (Nyquist convention) - - for (int i = startIdx; i < n; i++) { - double x = sweepReals[i]; - double y = -sweepImags[i]; // Nyquist Y is -Imag - - double x2 = x * x; - double y2 = y * y; - - sum_x += x; - sum_y += y; - sum_x2 += x2; - sum_y2 += y2; - sum_xy += x * y; - sum_x3 += x2 * x; - sum_y3 += y2 * y; - sum_xy2 += x * y2; - sum_x2y += x2 * y; - } - - // Solve Linear System for A, B, C - // | sum_x2 sum_xy sum_x | | A | | sum_x(x2+y2) | - // | sum_xy sum_y2 sum_y | | B | = | sum_y(x2+y2) | - // | sum_x sum_y N | | C | | sum(x2+y2) | - - double M11 = sum_x2, M12 = sum_xy, M13 = sum_x; - double M21 = sum_xy, M22 = sum_y2, M23 = sum_y; - double M31 = sum_x, M32 = sum_y, M33 = (double)count; - - double R1 = sum_x3 + sum_xy2; - double R2 = sum_x2y + sum_y3; - double R3 = sum_x2 + sum_y2; - - // Determinant of M - double det = M11*(M22*M33 - M23*M32) - M12*(M21*M33 - M23*M31) + M13*(M21*M32 - M22*M31); - - if (std::abs(det) < 1e-9) return; // Singular - - // Cramer's Rule for A, B, C - double detA = R1*(M22*M33 - M23*M32) - M12*(R2*M33 - M23*R3) + M13*(R2*M32 - M22*R3); - double detB = M11*(R2*M33 - M23*R3) - R1*(M21*M33 - M23*M31) + M13*(M21*R3 - R2*M31); - double detC = M11*(M22*R3 - R2*M32) - M12*(M21*R3 - R2*M31) + R1*(M21*M32 - M22*M31); - - double A = detA / det; - double B = detB / det; - double C = detC / det; - - // Circle Parameters - // Center (xc, yc) = (A/2, B/2) - // Radius r = sqrt(C + xc^2 + yc^2) - - double xc = A / 2.0; - // double yc = B / 2.0; // Not needed for intercept - double r_sq = C + (A*A)/4.0 + (B*B)/4.0; - - if (r_sq < 0) return; // Invalid fit - - // Find Intercepts with Real Axis (y = 0) - // Equation: x^2 + y^2 - Ax - By - C = 0 - // Set y=0: x^2 - Ax - C = 0 - // x = (A +/- sqrt(A^2 + 4C)) / 2 - - double disc = A*A + 4*C; - if (disc < 0) return; // No real intercept - - double x1 = (A - std::sqrt(disc)) / 2.0; - double x2 = (A + std::sqrt(disc)) / 2.0; - - // Rs is typically the smaller positive intercept (High Frequency) - // Rct + Rs is the larger intercept (Low Frequency) - double Rs = 0; - if (x1 > 0 && x2 > 0) Rs = std::min(x1, x2); - else if (x1 > 0) Rs = x1; - else if (x2 > 0) Rs = x2; - else Rs = std::max(x1, x2); // Fallback - - // Update UI - lblResultRs->setText(QString(" Rs: %1 Ω").arg(Rs, 0, 'f', 2)); - - // Calculate Conductivity: Cond = K / Rs - // K is in cm^-1, Rs in Ohms -> S/cm -> * 1e6 for uS/cm - double cond = (cellConstant / Rs) * 1000000.0; - lblResultCond->setText(QString(" Cond: %1 µS/cm").arg(cond, 0, 'f', 2)); - - // Update Graph Marker - nyquistGraph->setExtrapolatedPoint(Rs, 0); -} - -void MainWindow::calibrateCellConstant() { - // Parse current Rs from label (dirty but effective given the flow) - QString txt = lblResultRs->text(); - if (txt.contains("--")) { - QMessageBox::warning(this, "Calibration Error", "No valid Rs measurement found. Run a sweep first."); - return; - } - - // Extract number from string " Rs: 123.45 Ω" - QString numStr = txt.section(':', 1).section(QChar(0x03A9), 0, 0).trimmed(); // 0x03A9 is Omega - double measuredRs = numStr.toDouble(); - - if (measuredRs <= 0) return; - - double stdCond = spinCondStd->value(); // uS/cm - - // K = Rs * Cond - // Units: Ohm * (uS/cm) * 1e-6 = Ohm * S/cm * 1e-6 = (1/cm) * 1e-6 ??? - // Wait: Cond = K / Rs => K = Cond * Rs - // K [cm^-1] = (uS/cm * 1e-6) [S/cm] * Rs [Ohm] - - cellConstant = (stdCond * 1e-6) * measuredRs; - - saveSettings(); - - QMessageBox::information(this, "Calibration Success", - QString("Cell Constant (K) calibrated to: %1 cm⁻¹").arg(cellConstant, 0, 'f', 4)); - - // Re-run fit to update display with new K - performCircleFit(); -} - -void MainWindow::setButtonBlinking(QPushButton *btn, bool blinking) { - if (activeButton && activeButton != btn) { - // Reset previous button - activeButton->setStyleSheet(""); - } - - activeButton = btn; - - if (blinking) { - blinkTimer->start(); - } else { - blinkTimer->stop(); - if (activeButton) activeButton->setStyleSheet(""); - activeButton = nullptr; - } -} - -void MainWindow::onBlinkTimer() { - if (!activeButton) return; - - blinkState = !blinkState; - if (blinkState) { - activeButton->setStyleSheet("background-color: #FF0000; color: white; border: 1px solid #FF4444;"); - } else { - activeButton->setStyleSheet("background-color: #880000; color: white; border: 1px solid #AA0000;"); - } -} - bool MainWindow::event(QEvent *event) { if (event->type() == QEvent::Gesture) { QGestureEvent *ge = static_cast(event); diff --git a/host/src/MainWindow.h b/host/src/MainWindow.h index 405b815..e9f3c99 100644 --- a/host/src/MainWindow.h +++ b/host/src/MainWindow.h @@ -1,4 +1,4 @@ -// host/src/MainWindow.h +// File: host/src/MainWindow.h #pragma once #include @@ -29,37 +29,43 @@ protected: bool event(QEvent *event) override; private slots: + // Serial Slots (MainWindow_Serial.cpp) void handleSerialData(); void connectToPort(); void refreshPorts(); void onPortError(QSerialPort::SerialPortError error); - void onBlinkTimer(); - // Action Slots + // UI Slots (MainWindow_UI.cpp / MainWindow.cpp) + void onBlinkTimer(); + void onLPFChanged(int index); + + // Action Slots (MainWindow_Actions.cpp) void checkDeviceId(); void runCalibration(); void startSweep(); void toggleMeasurement(); void toggleAmperometry(); - // LSV Slots + // LSV Slots (MainWindow_Actions.cpp) void startLSVBlank(); void startLSVSample(); void stopLSV(); void calibrateCellConstant(); - void onLPFChanged(int index); private: - void setupUi(); - void loadSettings(); - void saveSettings(); - void parseData(const QString &data); - void handleSwipe(QSwipeGesture *gesture); - void computeHilbert(); - void performCircleFit(); - void calculateLSVDiff(); - void setButtonBlinking(QPushButton *btn, bool blinking); + // Initialization Methods + void setupUi(); // In MainWindow_UI.cpp + void loadSettings(); // In MainWindow.cpp + void saveSettings(); // In MainWindow.cpp + + // Logic Helpers + void parseData(const QString &data); // In MainWindow_Serial.cpp + void handleSwipe(QSwipeGesture *gesture); // In MainWindow.cpp + void computeHilbert(); // In MainWindow_Actions.cpp + void performCircleFit(); // In MainWindow_Actions.cpp + void calculateLSVDiff(); // In MainWindow_Actions.cpp + void setButtonBlinking(QPushButton *btn, bool blinking); // In MainWindow_UI.cpp QSerialPort *serial; QSettings *settings; @@ -74,7 +80,7 @@ private: GraphWidget *lsvGraph; QTextEdit *logWidget; - // Layout + // Layout Elements QTabWidget *tabWidget; QComboBox *portSelector; QPushButton *connectBtn; @@ -97,7 +103,7 @@ private: // Amperometry Configuration QDoubleSpinBox *spinAmpBias; QPushButton *ampBtn; - QComboBox *comboLPF; // New LPF Dropdown + QComboBox *comboLPF; // LSV Configuration QDoubleSpinBox *spinLsvStart; @@ -115,11 +121,11 @@ private: double cellConstant = 1.0; + // State Flags bool isMeasuringImp = false; bool isMeasuringAmp = false; bool isSweeping = false; - // LSV State enum LSVState { LSV_IDLE, LSV_RUNNING_BLANK, LSV_RUNNING_SAMPLE }; LSVState lsvState = LSV_IDLE; @@ -128,7 +134,7 @@ private: QVector sweepReals; QVector sweepImags; - // LSV Data Storage for Diff Calculation + // LSV Data Storage struct LSVPoint { double voltage; double current; }; QVector lsvBlankData; QVector lsvSampleData; diff --git a/host/src/MainWindow_Actions.cpp b/host/src/MainWindow_Actions.cpp new file mode 100644 index 0000000..e50decb --- /dev/null +++ b/host/src/MainWindow_Actions.cpp @@ -0,0 +1,403 @@ +// File: host/src/MainWindow_Actions.cpp +#include "MainWindow.h" +#include +#include +#include +#include + +void MainWindow::checkDeviceId() { + if (serial->isOpen()) serial->write("v\n"); +} + +void MainWindow::runCalibration() { + if (serial->isOpen()) { + int rangeVal = comboRange->currentData().toInt(); + serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); + serial->write("c\n"); + } +} + +void MainWindow::onLPFChanged(int index) { + if (serial->isOpen()) { + int val = comboLPF->itemData(index).toInt(); + serial->write(QString("f %1\n").arg(val).toUtf8()); + } +} + +void MainWindow::startSweep() { + if (!serial->isOpen()) return; + + if (isSweeping) { + // Stop Sweep + serial->write("x\n"); + return; + } + + if (isMeasuringAmp) toggleAmperometry(); + if (lsvState != LSV_IDLE) stopLSV(); + if (isMeasuringImp) toggleMeasurement(); + + rawGraph->clear(); + nyquistGraph->clear(); + + sweepFreqs.clear(); + sweepReals.clear(); + sweepImags.clear(); + + double start = spinSweepStart->value(); + double stop = spinSweepStop->value(); + int ppd = spinSweepPPD->value(); + int rangeVal = comboRange->currentData().toInt(); + + logWidget->append(QString(">> Starting Sweep (Range: %1, s %2 %3 %4)...").arg(rangeVal).arg(start).arg(stop).arg(ppd)); + + serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); + serial->write(QString("s %1 %2 %3\n").arg(start).arg(stop).arg(ppd).toUtf8()); + + isSweeping = true; + sweepBtn->setText("Stop Sweep"); + setButtonBlinking(sweepBtn, true); + tabWidget->setCurrentIndex(1); +} + +void MainWindow::toggleMeasurement() { + if (!serial->isOpen()) return; + if (isMeasuringAmp) toggleAmperometry(); + if (lsvState != LSV_IDLE) stopLSV(); + if (isSweeping) startSweep(); // Stop sweep + + if (isMeasuringImp) { + serial->write("x\n"); + measureBtn->setText("Measure"); + setButtonBlinking(nullptr, false); + isMeasuringImp = false; + } else { + double freq = spinFreq->value(); + int rangeVal = comboRange->currentData().toInt(); + + serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); + serial->write(QString("m %1\n").arg(freq).toUtf8()); + + measureBtn->setText("Stop"); + setButtonBlinking(measureBtn, true); + isMeasuringImp = true; + tabWidget->setCurrentIndex(0); + } +} + +void MainWindow::toggleAmperometry() { + if (!serial->isOpen()) return; + if (isMeasuringImp) toggleMeasurement(); + if (lsvState != LSV_IDLE) stopLSV(); + if (isSweeping) startSweep(); + + if (isMeasuringAmp) { + serial->write("x\n"); + ampBtn->setText("Start Amp"); + setButtonBlinking(nullptr, false); + isMeasuringAmp = false; + } else { + double bias = spinAmpBias->value(); + int rangeVal = comboRange->currentData().toInt(); + + serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); + serial->write(QString("a %1\n").arg(bias).toUtf8()); + + ampBtn->setText("Stop Amp"); + setButtonBlinking(ampBtn, true); + isMeasuringAmp = true; + ampGraph->clear(); + tabWidget->setCurrentIndex(2); + } +} + +void MainWindow::startLSVBlank() { + if (!serial->isOpen()) return; + if (isMeasuringImp) toggleMeasurement(); + if (isMeasuringAmp) toggleAmperometry(); + if (lsvState != LSV_IDLE) stopLSV(); + if (isSweeping) startSweep(); + + double start = spinLsvStart->value(); + double stop = spinLsvStop->value(); + int steps = spinLsvSteps->value(); + int duration = spinLsvDuration->value(); + int rangeVal = comboRange->currentData().toInt(); + + logWidget->append(QString(">> Starting LSV Blank (Range: %1, %.1f to %.1f mV)...").arg(rangeVal).arg(start).arg(stop)); + + serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); + serial->write(QString("l %1 %2 %3 %4\n").arg(start).arg(stop).arg(steps).arg(duration).toUtf8()); + + lsvBlankBtn->setText("Stop Blank"); + setButtonBlinking(lsvBlankBtn, true); + lsvState = LSV_RUNNING_BLANK; + + lsvGraph->clearLSV(GraphWidget::LSV_BLANK); + lsvGraph->clearLSV(GraphWidget::LSV_DIFF); // Clear old diff + lsvBlankData.clear(); + + tabWidget->setCurrentIndex(3); +} + +void MainWindow::startLSVSample() { + if (!serial->isOpen()) return; + if (isMeasuringImp) toggleMeasurement(); + if (isMeasuringAmp) toggleAmperometry(); + if (lsvState != LSV_IDLE) stopLSV(); + if (isSweeping) startSweep(); + + double start = spinLsvStart->value(); + double stop = spinLsvStop->value(); + int steps = spinLsvSteps->value(); + int duration = spinLsvDuration->value(); + int rangeVal = comboRange->currentData().toInt(); + + logWidget->append(QString(">> Starting LSV Sample (Range: %1, %.1f to %.1f mV)...").arg(rangeVal).arg(start).arg(stop)); + + serial->write(QString("r %1\n").arg(rangeVal).toUtf8()); + serial->write(QString("l %1 %2 %3 %4\n").arg(start).arg(stop).arg(steps).arg(duration).toUtf8()); + + lsvSampleBtn->setText("Stop Sample"); + setButtonBlinking(lsvSampleBtn, true); + lsvState = LSV_RUNNING_SAMPLE; + + lsvGraph->clearLSV(GraphWidget::LSV_SAMPLE); + lsvGraph->clearLSV(GraphWidget::LSV_DIFF); // Clear old diff + lsvSampleData.clear(); + + tabWidget->setCurrentIndex(3); +} + +void MainWindow::stopLSV() { + serial->write("x\n"); + lsvBlankBtn->setText("Run Blank"); + lsvSampleBtn->setText("Run Sample"); + setButtonBlinking(nullptr, false); + + // If we just finished a sample run, calculate the difference + if (lsvState == LSV_RUNNING_SAMPLE) { + calculateLSVDiff(); + } + + lsvState = LSV_IDLE; +} + +void MainWindow::calculateLSVDiff() { + if (lsvBlankData.isEmpty() || lsvSampleData.isEmpty()) return; + + lsvGraph->clearLSV(GraphWidget::LSV_DIFF); + + // Simple index-based subtraction (assumes same parameters used) + int count = std::min(lsvBlankData.size(), lsvSampleData.size()); + + for (int i = 0; i < count; i++) { + double v = lsvSampleData[i].voltage; // Use sample voltage + double diffCurrent = lsvSampleData[i].current - lsvBlankData[i].current; + lsvGraph->addLSVData(v, diffCurrent, GraphWidget::LSV_DIFF); + } + + logWidget->append(">> Calculated Difference Curve."); +} + +void MainWindow::computeHilbert() { + int n = sweepReals.size(); + if (n == 0) return; + + // Pad to next power of 2 for efficiency and consistency with previous logic + int n_fft = 1; + while (n_fft < n) n_fft *= 2; + + // Allocate FFTW arrays + fftw_complex *in = (fftw_complex*) fftw_malloc(sizeof(fftw_complex) * n_fft); + fftw_complex *out = (fftw_complex*) fftw_malloc(sizeof(fftw_complex) * n_fft); + + // Create Plans (ESTIMATE is fast for one-off sizes) + fftw_plan p_fwd = fftw_plan_dft_1d(n_fft, in, out, FFTW_FORWARD, FFTW_ESTIMATE); + fftw_plan p_bwd = fftw_plan_dft_1d(n_fft, out, in, FFTW_BACKWARD, FFTW_ESTIMATE); + + // Prepare Input: Copy data and zero-pad + for (int i = 0; i < n; i++) { + in[i][0] = sweepReals[i]; // Real part + in[i][1] = 0.0; // Imag part + } + for (int i = n; i < n_fft; i++) { + in[i][0] = 0.0; + in[i][1] = 0.0; + } + + // Forward FFT + fftw_execute(p_fwd); + + // Apply Hilbert Mask in Frequency Domain (Analytic Signal) + // H[0] (DC) and H[N/2] (Nyquist) are left alone. + // Positive Frequencies (1 to N/2 - 1) multiplied by 2. + // Negative Frequencies (N/2 + 1 to N - 1) zeroed out. + + int half_n = n_fft / 2; + + // Multiply positive frequencies by 2 + for (int i = 1; i < half_n; i++) { + out[i][0] *= 2.0; + out[i][1] *= 2.0; + } + + // Zero out negative frequencies + for (int i = half_n + 1; i < n_fft; i++) { + out[i][0] = 0.0; + out[i][1] = 0.0; + } + + // Inverse FFT + fftw_execute(p_bwd); + + // Extract Imaginary part of Analytic Signal (Hilbert Transform) + // Note: FFTW IFFT is unnormalized, divide by N + QVector hilbertImag; + hilbertImag.reserve(n); + + for (int i = 0; i < n; i++) { + double val = in[i][1] / n_fft; // Imaginary part normalized + hilbertImag.append(val); + } + + rawGraph->addHilbertData(sweepFreqs, hilbertImag); + + // Cleanup + fftw_destroy_plan(p_fwd); + fftw_destroy_plan(p_bwd); + fftw_free(in); + fftw_free(out); +} + +void MainWindow::performCircleFit() { + int n = sweepReals.size(); + if (n < 5) return; + + double measuredRs = -1.0; + bool found = false; + + for (int i = 0; i < n - 1; i++) { + double img1 = sweepImags[i]; + double img2 = sweepImags[i+1]; + + if ((img1 >= 0 && img2 < 0) || (img1 < 0 && img2 >= 0)) { + double r1 = sweepReals[i]; + double r2 = sweepReals[i+1]; + + if (std::abs(img2 - img1) < 1e-9) continue; + + double fraction = (0.0 - img1) / (img2 - img1); + double crossingReal = r1 + fraction * (r2 - r1); + + if (!found || crossingReal < measuredRs) { + measuredRs = crossingReal; + found = true; + } + } + } + + if (found && measuredRs > 0) { + lblResultRs->setText(QString(" Rs: %1 Ω (Meas)").arg(measuredRs, 0, 'f', 2)); + + double cond = (cellConstant / measuredRs) * 1000000.0; + lblResultCond->setText(QString(" Cond: %1 µS/cm").arg(cond, 0, 'f', 2)); + + nyquistGraph->setExtrapolatedPoint(measuredRs, 0); + return; + } + + int startIdx = n - (n / 3); + if (startIdx < 0) startIdx = 0; + int count = n - startIdx; + if (count < 3) return; + + double sum_x = 0, sum_y = 0, sum_x2 = 0, sum_y2 = 0; + double sum_xy = 0, sum_x3 = 0, sum_y3 = 0, sum_xy2 = 0, sum_x2y = 0; + + for (int i = startIdx; i < n; i++) { + double x = sweepReals[i]; + double y = -sweepImags[i]; + + double x2 = x * x; + double y2 = y * y; + + sum_x += x; + sum_y += y; + sum_x2 += x2; + sum_y2 += y2; + sum_xy += x * y; + sum_x3 += x2 * x; + sum_y3 += y2 * y; + sum_xy2 += x * y2; + sum_x2y += x2 * y; + } + + double M11 = sum_x2, M12 = sum_xy, M13 = sum_x; + double M21 = sum_xy, M22 = sum_y2, M23 = sum_y; + double M31 = sum_x, M32 = sum_y, M33 = (double)count; + + double R1 = sum_x3 + sum_xy2; + double R2 = sum_x2y + sum_y3; + double R3 = sum_x2 + sum_y2; + + double det = M11*(M22*M33 - M23*M32) - M12*(M21*M33 - M23*M31) + M13*(M21*M32 - M22*M31); + + if (std::abs(det) < 1e-9) return; + + double detA = R1*(M22*M33 - M23*M32) - M12*(R2*M33 - M23*R3) + M13*(R2*M32 - M22*R3); + double detB = M11*(R2*M33 - M23*R3) - R1*(M21*M33 - M23*M31) + M13*(M21*R3 - R2*M31); + double detC = M11*(M22*R3 - R2*M32) - M12*(M21*R3 - R2*M31) + R1*(M21*M32 - M22*M31); + + double A = detA / det; + double B = detB / det; + double C = detC / det; + + double xc = A / 2.0; + double r_sq = C + (A*A)/4.0 + (B*B)/4.0; + + if (r_sq < 0) return; + + double disc = A*A + 4*C; + if (disc < 0) return; + + double x1 = (A - std::sqrt(disc)) / 2.0; + double x2 = (A + std::sqrt(disc)) / 2.0; + + double Rs = 0; + if (x1 > 0 && x2 > 0) Rs = std::min(x1, x2); + else if (x1 > 0) Rs = x1; + else if (x2 > 0) Rs = x2; + else Rs = std::max(x1, x2); + + lblResultRs->setText(QString(" Rs: %1 Ω").arg(Rs, 0, 'f', 2)); + + double cond = (cellConstant / Rs) * 1000000.0; + lblResultCond->setText(QString(" Cond: %1 µS/cm").arg(cond, 0, 'f', 2)); + + nyquistGraph->setExtrapolatedPoint(Rs, 0); +} + +void MainWindow::calibrateCellConstant() { + QString txt = lblResultRs->text(); + if (txt.contains("--")) { + QMessageBox::warning(this, "Calibration Error", "No valid Rs measurement found. Run a sweep first."); + return; + } + + QString numStr = txt.section(':', 1).section(QChar(0x03A9), 0, 0).trimmed(); + double measuredRs = numStr.toDouble(); + + if (measuredRs <= 0) return; + + double stdCond = spinCondStd->value(); + + cellConstant = (stdCond * 1e-6) * measuredRs; + + saveSettings(); + + QMessageBox::information(this, "Calibration Success", + QString("Cell Constant (K) calibrated to: %1 cm⁻¹").arg(cellConstant, 0, 'f', 4)); + + performCircleFit(); +} \ No newline at end of file diff --git a/host/src/MainWindow_Serial.cpp b/host/src/MainWindow_Serial.cpp new file mode 100644 index 0000000..c40a857 --- /dev/null +++ b/host/src/MainWindow_Serial.cpp @@ -0,0 +1,240 @@ +// File: host/src/MainWindow_Serial.cpp +#include "MainWindow.h" +#include +#include + +void MainWindow::refreshPorts() { + portSelector->clear(); + const auto infos = QSerialPortInfo::availablePorts(); + bool foundTarget = false; + QString targetPort; + + for (const QSerialPortInfo &info : infos) { + portSelector->addItem(info.portName()); + bool isCafe = (info.hasVendorIdentifier() && info.vendorIdentifier() == 0xCAFE); + bool isUsbModem = info.portName().contains("usbmodem", Qt::CaseInsensitive); + if ((isCafe || isUsbModem) && !foundTarget) { + targetPort = info.portName(); + foundTarget = true; + logWidget->append(">> Found Target Device: " + targetPort); + } + } + if (foundTarget) { + portSelector->setCurrentText(targetPort); + if (!serial->isOpen()) connectToPort(); + } +} + +void MainWindow::connectToPort() { + if (serial->isOpen()) { + serial->close(); + connectBtn->setText("Connect"); + logWidget->append("--- Disconnected ---"); + checkIdBtn->setEnabled(false); + calibrateBtn->setEnabled(false); + sweepBtn->setEnabled(false); + measureBtn->setEnabled(false); + spinSweepStart->setEnabled(false); + spinSweepStop->setEnabled(false); + spinSweepPPD->setEnabled(false); + ampBtn->setEnabled(false); + spinAmpBias->setEnabled(false); + btnCalCond->setEnabled(false); + comboRange->setEnabled(false); + comboLPF->setEnabled(false); + lsvBlankBtn->setEnabled(false); + lsvSampleBtn->setEnabled(false); + spinLsvStart->setEnabled(false); + spinLsvStop->setEnabled(false); + spinLsvSteps->setEnabled(false); + spinLsvDuration->setEnabled(false); + + isMeasuringImp = false; + isMeasuringAmp = false; + isSweeping = false; + lsvState = LSV_IDLE; + setButtonBlinking(nullptr, false); + + measureBtn->setText("Measure"); + ampBtn->setText("Start Amp"); + lsvBlankBtn->setText("Run Blank"); + lsvSampleBtn->setText("Run Sample"); + return; + } + + if (portSelector->currentText().isEmpty()) return; + + serial->setPortName(portSelector->currentText()); + serial->setBaudRate(500000); + + if (serial->open(QIODevice::ReadWrite)) { + connectBtn->setText("Disconnect"); + logWidget->append("--- Connected and Synchronized ---"); + checkIdBtn->setEnabled(true); + calibrateBtn->setEnabled(true); + sweepBtn->setEnabled(true); + measureBtn->setEnabled(true); + spinSweepStart->setEnabled(true); + spinSweepStop->setEnabled(true); + spinSweepPPD->setEnabled(true); + ampBtn->setEnabled(true); + spinAmpBias->setEnabled(true); + btnCalCond->setEnabled(true); + comboRange->setEnabled(true); + comboLPF->setEnabled(true); + lsvBlankBtn->setEnabled(true); + lsvSampleBtn->setEnabled(true); + spinLsvStart->setEnabled(true); + spinLsvStop->setEnabled(true); + spinLsvSteps->setEnabled(true); + spinLsvDuration->setEnabled(true); + + // Sync LPF + onLPFChanged(comboLPF->currentIndex()); + } else { + logWidget->append(">> Connection Error: " + serial->errorString()); + } +} + +void MainWindow::onPortError(QSerialPort::SerialPortError error) { + if (error == QSerialPort::ResourceError) { + logWidget->append(">> Critical Error: Connection Lost."); + serial->close(); + connectBtn->setText("Connect"); + checkIdBtn->setEnabled(false); + calibrateBtn->setEnabled(false); + sweepBtn->setEnabled(false); + measureBtn->setEnabled(false); + spinSweepStart->setEnabled(false); + spinSweepStop->setEnabled(false); + spinSweepPPD->setEnabled(false); + ampBtn->setEnabled(false); + spinAmpBias->setEnabled(false); + btnCalCond->setEnabled(false); + comboRange->setEnabled(false); + comboLPF->setEnabled(false); + lsvBlankBtn->setEnabled(false); + lsvSampleBtn->setEnabled(false); + spinLsvStart->setEnabled(false); + spinLsvStop->setEnabled(false); + spinLsvSteps->setEnabled(false); + spinLsvDuration->setEnabled(false); + setButtonBlinking(nullptr, false); + } +} + +void MainWindow::handleSerialData() { + while (serial->canReadLine()) { + QByteArray line = serial->readLine(); + QString str = QString::fromUtf8(line).trimmed(); + if (str.isEmpty()) continue; + + QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss.zzz"); + logWidget->append(QString("[%1] %2").arg(timestamp, str)); + logWidget->moveCursor(QTextCursor::End); + + if (str.startsWith("DATA,")) { + parseData(str); + } else if (str.startsWith("AMP,")) { + parseData(str); + } else if (str.startsWith("RAMP,")) { + parseData(str); + } else if (str == "STOPPED") { + // Reset UI state + if (lsvState != LSV_IDLE) stopLSV(); + if (isSweeping) { + isSweeping = false; + sweepBtn->setText("Sweep"); + setButtonBlinking(nullptr, false); + } + if (isMeasuringImp) { + isMeasuringImp = false; + measureBtn->setText("Measure"); + setButtonBlinking(nullptr, false); + } + if (isMeasuringAmp) { + isMeasuringAmp = false; + ampBtn->setText("Start Amp"); + setButtonBlinking(nullptr, false); + } + } + } +} + +void MainWindow::parseData(const QString &data) { + QStringList parts = data.split(','); + + if (parts[0] == "AMP" && parts.size() >= 3) { + bool okIdx, okCurr; + double index = parts[1].toDouble(&okIdx); + double current = parts[2].toDouble(&okCurr); + if (okIdx && okCurr) ampGraph->addAmperometricData(index, current); + return; + } + + if (parts[0] == "RAMP" && parts.size() >= 3) { + bool okIdx, okCurr; + double index = parts[1].toDouble(&okIdx); + double current = parts[2].toDouble(&okCurr); + + if (okIdx && okCurr) { + // Calculate Voltage based on UI parameters (Host-side calculation) + double start = spinLsvStart->value(); + double stop = spinLsvStop->value(); + int steps = spinLsvSteps->value(); + + double voltage = start + (index * (stop - start) / steps); + + if (lsvState == LSV_RUNNING_BLANK) { + lsvGraph->addLSVData(voltage, current, GraphWidget::LSV_BLANK); + lsvBlankData.append({voltage, current}); + } else if (lsvState == LSV_RUNNING_SAMPLE) { + lsvGraph->addLSVData(voltage, current, GraphWidget::LSV_SAMPLE); + lsvSampleData.append({voltage, current}); + } + } + return; + } + + if (parts[0] == "DATA" && parts.size() >= 6) { + bool okF, okR, okI; + double freq = parts[1].toDouble(&okF); + double real = parts[4].toDouble(&okR); + double imag = parts[5].toDouble(&okI); + + if (okF && okR && okI) { + double real_plot = real; + double imag_plot = imag; + bool showCorr = false; + + if (checkShunt->isChecked()) { + showCorr = true; + double r_shunt = spinShuntRes->value(); + std::complex z_meas(real, imag); + std::complex z_shunt(r_shunt, 0.0); + std::complex denom = z_shunt - z_meas; + + if (std::abs(denom) > 1e-9) { + std::complex z_cell = (z_meas * z_shunt) / denom; + real_plot = z_cell.real(); + imag_plot = z_cell.imag(); + } else { + real_plot = 1e9; + imag_plot = 0; + } + } + + rawGraph->addBodeData(freq, real_plot, imag_plot); + nyquistGraph->addNyquistData(real, imag, real_plot, imag_plot, showCorr); + + sweepFreqs.append(freq); + sweepReals.append(real_plot); + sweepImags.append(imag_plot); + + if (sweepReals.size() > 10 && sweepReals.size() % 10 == 0) { + computeHilbert(); + performCircleFit(); + } + } + } +} \ No newline at end of file diff --git a/host/src/MainWindow_UI.cpp b/host/src/MainWindow_UI.cpp new file mode 100644 index 0000000..c84de13 --- /dev/null +++ b/host/src/MainWindow_UI.cpp @@ -0,0 +1,328 @@ +// File: host/src/MainWindow_UI.cpp +#include "MainWindow.h" +#include +#include +#include +#include +#include + +void MainWindow::setupUi() { + QWidget *central = new QWidget(this); + setCentralWidget(central); + QVBoxLayout *mainLayout = new QVBoxLayout(central); + mainLayout->setContentsMargins(5, 5, 5, 5); + mainLayout->setSpacing(5); + + // --- Controls Container --- + QWidget *controlsContainer = new QWidget(this); + controlsContainer->setStyleSheet("background-color: #3A3A3A; border-radius: 5px;"); + QVBoxLayout *ctrlLayout = new QVBoxLayout(controlsContainer); + ctrlLayout->setContentsMargins(5, 5, 5, 5); + ctrlLayout->setSpacing(5); + + // Row 1 + QHBoxLayout *row1 = new QHBoxLayout(); + row1->setSpacing(8); + + portSelector = new QComboBox(this); + portSelector->setMinimumWidth(120); + + connectBtn = new QPushButton("Connect", this); + // Disconnect color (Red 50%) will be set when connected, default is Connect + connectBtn->setStyleSheet("background-color: rgba(255, 0, 0, 128); color: white; font-weight: bold;"); + + QPushButton *refreshBtn = new QPushButton("Refresh", this); + refreshBtn->setStyleSheet("background-color: rgba(255, 255, 255, 178); color: black; font-weight: bold;"); + + row1->addWidget(portSelector); + row1->addWidget(connectBtn); + row1->addWidget(refreshBtn); + + QFrame *sep1 = new QFrame(); sep1->setFrameShape(QFrame::VLine); sep1->setFrameShadow(QFrame::Sunken); + row1->addWidget(sep1); + + checkIdBtn = new QPushButton("Check ID", this); + checkIdBtn->setStyleSheet("background-color: rgba(255, 255, 255, 166); color: black; font-weight: bold;"); + + calibrateBtn = new QPushButton("Calibrate HW", this); + calibrateBtn->setStyleSheet("background-color: rgba(255, 255, 0, 102); color: white; font-weight: bold;"); + + row1->addWidget(checkIdBtn); + row1->addWidget(calibrateBtn); + + QFrame *sep2 = new QFrame(); sep2->setFrameShape(QFrame::VLine); sep2->setFrameShadow(QFrame::Sunken); + row1->addWidget(sep2); + + row1->addWidget(new QLabel("Start:")); + spinSweepStart = new QDoubleSpinBox(this); + spinSweepStart->setRange(0.1, 200000.0); spinSweepStart->setValue(1000.0); spinSweepStart->setSuffix(" Hz"); + row1->addWidget(spinSweepStart); + + row1->addWidget(new QLabel("Stop:")); + spinSweepStop = new QDoubleSpinBox(this); + spinSweepStop->setRange(0.1, 200000.0); spinSweepStop->setValue(200000.0); spinSweepStop->setSuffix(" Hz"); + row1->addWidget(spinSweepStop); + + row1->addWidget(new QLabel("PPD:")); + spinSweepPPD = new QSpinBox(this); + spinSweepPPD->setRange(1, 1000); spinSweepPPD->setValue(200); spinSweepPPD->setSuffix(" pts/dec"); + row1->addWidget(spinSweepPPD); + + sweepBtn = new QPushButton("Sweep", this); + sweepBtn->setStyleSheet("background-color: rgba(173, 216, 230, 76); color: white; font-weight: bold;"); + row1->addWidget(sweepBtn); + + QFrame *sep3 = new QFrame(); sep3->setFrameShape(QFrame::VLine); sep3->setFrameShadow(QFrame::Sunken); + row1->addWidget(sep3); + + row1->addWidget(new QLabel("Shunt:")); + checkShunt = new QCheckBox("Enable", this); + row1->addWidget(checkShunt); + spinShuntRes = new QDoubleSpinBox(this); + spinShuntRes->setRange(1.0, 1000000.0); spinShuntRes->setValue(466.0); spinShuntRes->setSuffix(" Ω"); + row1->addWidget(spinShuntRes); + + QFrame *sep4 = new QFrame(); sep4->setFrameShape(QFrame::VLine); sep4->setFrameShadow(QFrame::Sunken); + row1->addWidget(sep4); + + row1->addWidget(new QLabel("Std Cond:")); + spinCondStd = new QDoubleSpinBox(this); + spinCondStd->setRange(0.0, 1000000.0); spinCondStd->setValue(1413.0); spinCondStd->setSuffix(" µS/cm"); + row1->addWidget(spinCondStd); + + btnCalCond = new QPushButton("Calibrate K", this); + btnCalCond->setStyleSheet("background-color: rgba(255, 165, 0, 102); color: white; font-weight: bold;"); + row1->addWidget(btnCalCond); + + lblResultRs = new QLabel(" Rs: -- Ω", this); + lblResultRs->setStyleSheet("font-weight: bold; color: #FFD700; font-size: 14px; margin-left: 10px;"); + row1->addWidget(lblResultRs); + + row1->addStretch(); + ctrlLayout->addLayout(row1); + + // Row 2 + QHBoxLayout *row2 = new QHBoxLayout(); + row2->setSpacing(8); + + row2->addWidget(new QLabel("Range:")); + comboRange = new QComboBox(this); + // Full 26 Resistor List + comboRange->addItem("200 Ω", 200); + comboRange->addItem("1 kΩ", 1000); + comboRange->addItem("2 kΩ", 2000); + comboRange->addItem("3 kΩ", 3000); + comboRange->addItem("4 kΩ", 4000); + comboRange->addItem("6 kΩ", 6000); + comboRange->addItem("8 kΩ", 8000); + comboRange->addItem("10 kΩ", 10000); + comboRange->addItem("12 kΩ", 12000); + comboRange->addItem("16 kΩ", 16000); + comboRange->addItem("20 kΩ", 20000); + comboRange->addItem("24 kΩ", 24000); + comboRange->addItem("30 kΩ", 30000); + comboRange->addItem("32 kΩ", 32000); + comboRange->addItem("40 kΩ", 40000); + comboRange->addItem("48 kΩ", 48000); + comboRange->addItem("64 kΩ", 64000); + comboRange->addItem("85 kΩ", 85000); + comboRange->addItem("96 kΩ", 96000); + comboRange->addItem("100 kΩ", 100000); + comboRange->addItem("120 kΩ", 120000); + comboRange->addItem("128 kΩ", 128000); + comboRange->addItem("160 kΩ", 160000); + comboRange->addItem("196 kΩ", 196000); + comboRange->addItem("256 kΩ", 256000); + comboRange->addItem("512 kΩ", 512000); + comboRange->setCurrentIndex(1); // Default 1k + row2->addWidget(comboRange); + + QFrame *sepRange = new QFrame(); sepRange->setFrameShape(QFrame::VLine); sepRange->setFrameShadow(QFrame::Sunken); + row2->addWidget(sepRange); + + lblResultCond = new QLabel("Cond: -- µS/cm", this); + lblResultCond->setStyleSheet("font-weight: bold; color: #00FFFF; font-size: 14px; margin-right: 10px;"); + row2->addWidget(lblResultCond); + + QFrame *sep5 = new QFrame(); sep5->setFrameShape(QFrame::VLine); sep5->setFrameShadow(QFrame::Sunken); + row2->addWidget(sep5); + + row2->addWidget(new QLabel("Freq:")); + spinFreq = new QDoubleSpinBox(this); + spinFreq->setRange(0.1, 200000.0); spinFreq->setValue(1000.0); spinFreq->setSuffix(" Hz"); + row2->addWidget(spinFreq); + + measureBtn = new QPushButton("Measure", this); + measureBtn->setStyleSheet("background-color: rgba(0, 100, 0, 76); color: white; font-weight: bold;"); + row2->addWidget(measureBtn); + + QFrame *sep6 = new QFrame(); sep6->setFrameShape(QFrame::VLine); sep6->setFrameShadow(QFrame::Sunken); + row2->addWidget(sep6); + + row2->addWidget(new QLabel("Bias:")); + spinAmpBias = new QDoubleSpinBox(this); + spinAmpBias->setRange(-1100.0, 1100.0); spinAmpBias->setValue(0.0); spinAmpBias->setSuffix(" mV"); + row2->addWidget(spinAmpBias); + + row2->addWidget(new QLabel("LPF:")); + comboLPF = new QComboBox(this); + comboLPF->addItem("Bypass", 0); + comboLPF->addItem("20kΩ (8Hz)", 1); + comboLPF->addItem("100kΩ (1.6Hz)", 2); + comboLPF->addItem("200kΩ (0.8Hz)", 3); + comboLPF->addItem("400kΩ (0.4Hz)", 4); + comboLPF->addItem("600kΩ (0.26Hz)", 5); + comboLPF->addItem("1MΩ (0.16Hz)", 6); + comboLPF->setCurrentIndex(1); // Default 20k + row2->addWidget(comboLPF); + + ampBtn = new QPushButton("Start Amp", this); + ampBtn->setStyleSheet("background-color: rgba(238, 130, 238, 51); color: white; font-weight: bold;"); + row2->addWidget(ampBtn); + + QFrame *sep7 = new QFrame(); sep7->setFrameShape(QFrame::VLine); sep7->setFrameShadow(QFrame::Sunken); + row2->addWidget(sep7); + + // LSV Controls + row2->addWidget(new QLabel("LSV:")); + spinLsvStart = new QDoubleSpinBox(this); + spinLsvStart->setRange(-1100.0, 1100.0); spinLsvStart->setValue(800.0); spinLsvStart->setSuffix(" mV"); + row2->addWidget(spinLsvStart); + + spinLsvStop = new QDoubleSpinBox(this); + spinLsvStop->setRange(-1100.0, 1100.0); spinLsvStop->setValue(-200.0); spinLsvStop->setSuffix(" mV"); + row2->addWidget(spinLsvStop); + + spinLsvSteps = new QSpinBox(this); + spinLsvSteps->setRange(10, 4000); spinLsvSteps->setValue(200); spinLsvSteps->setSuffix(" pts"); + row2->addWidget(spinLsvSteps); + + spinLsvDuration = new QSpinBox(this); + spinLsvDuration->setRange(100, 600000); spinLsvDuration->setValue(10000); spinLsvDuration->setSuffix(" ms"); + row2->addWidget(spinLsvDuration); + + lsvBlankBtn = new QPushButton("Run Blank", this); + lsvBlankBtn->setStyleSheet("background-color: rgba(0, 0, 0, 255); color: white; font-weight: bold;"); + row2->addWidget(lsvBlankBtn); + + lsvSampleBtn = new QPushButton("Run Sample", this); + lsvSampleBtn->setStyleSheet("background-color: rgba(75, 0, 130, 76); color: white; font-weight: bold;"); + row2->addWidget(lsvSampleBtn); + + row2->addStretch(); + ctrlLayout->addLayout(row2); + + mainLayout->addWidget(controlsContainer); + + // --- Splitter for Graphs and Log --- + QSplitter *splitter = new QSplitter(Qt::Vertical, this); + + tabWidget = new QTabWidget(this); + rawGraph = new GraphWidget(this); + nyquistGraph = new GraphWidget(this); + ampGraph = new GraphWidget(this); + lsvGraph = new GraphWidget(this); + + rawGraph->configureRawPlot(); + nyquistGraph->configureNyquistPlot(); + ampGraph->configureAmperometricPlot(); + lsvGraph->configureLSVPlot(); + + tabWidget->addTab(rawGraph, "Raw Data"); + tabWidget->addTab(nyquistGraph, "Nyquist Plot"); + tabWidget->addTab(ampGraph, "Amperometry"); + tabWidget->addTab(lsvGraph, "Voltammetry"); + + logWidget = new QTextEdit(this); + logWidget->setReadOnly(true); + logWidget->setFont(QFont("Monospace")); + logWidget->setPlaceholderText("Scanning for 0xCAFE EIS Device..."); + logWidget->setStyleSheet("background-color: #1E1E1E; color: #00FF00; border: 1px solid #444;"); + QScroller::grabGesture(logWidget->viewport(), QScroller::TouchGesture); + + splitter->addWidget(tabWidget); + splitter->addWidget(logWidget); + splitter->setStretchFactor(0, 3); + splitter->setStretchFactor(1, 1); + + mainLayout->addWidget(splitter); + + // Initial State + checkIdBtn->setEnabled(false); + calibrateBtn->setEnabled(false); + sweepBtn->setEnabled(false); + measureBtn->setEnabled(false); + spinSweepStart->setEnabled(false); + spinSweepStop->setEnabled(false); + spinSweepPPD->setEnabled(false); + ampBtn->setEnabled(false); + spinAmpBias->setEnabled(false); + btnCalCond->setEnabled(false); + comboRange->setEnabled(false); + comboLPF->setEnabled(false); + lsvBlankBtn->setEnabled(false); + lsvSampleBtn->setEnabled(false); + spinLsvStart->setEnabled(false); + spinLsvStop->setEnabled(false); + spinLsvSteps->setEnabled(false); + spinLsvDuration->setEnabled(false); + + // Connections + connect(connectBtn, &QPushButton::clicked, this, &MainWindow::connectToPort); + connect(refreshBtn, &QPushButton::clicked, this, &MainWindow::refreshPorts); + connect(checkIdBtn, &QPushButton::clicked, this, &MainWindow::checkDeviceId); + connect(calibrateBtn, &QPushButton::clicked, this, &MainWindow::runCalibration); + connect(sweepBtn, &QPushButton::clicked, this, &MainWindow::startSweep); + connect(measureBtn, &QPushButton::clicked, this, &MainWindow::toggleMeasurement); + connect(ampBtn, &QPushButton::clicked, this, &MainWindow::toggleAmperometry); + connect(lsvBlankBtn, &QPushButton::clicked, this, &MainWindow::startLSVBlank); + connect(lsvSampleBtn, &QPushButton::clicked, this, &MainWindow::startLSVSample); + connect(btnCalCond, &QPushButton::clicked, this, &MainWindow::calibrateCellConstant); + connect(comboLPF, QOverload::of(&QComboBox::currentIndexChanged), this, &MainWindow::onLPFChanged); + + connect(tabWidget, &QTabWidget::currentChanged, this, [this](int index){ + if (index == 0) rawGraph->configureRawPlot(); + else if (index == 1) nyquistGraph->configureNyquistPlot(); + else if (index == 2) ampGraph->configureAmperometricPlot(); + else if (index == 3) lsvGraph->configureLSVPlot(); + }); +} + +void MainWindow::setButtonBlinking(QPushButton *btn, bool blinking) { + if (activeButton && activeButton != btn) { + // Restore original colors manually + if (activeButton == sweepBtn) activeButton->setStyleSheet("background-color: rgba(173, 216, 230, 76); color: white; font-weight: bold;"); + else if (activeButton == measureBtn) activeButton->setStyleSheet("background-color: rgba(0, 100, 0, 76); color: white; font-weight: bold;"); + else if (activeButton == ampBtn) activeButton->setStyleSheet("background-color: rgba(238, 130, 238, 51); color: white; font-weight: bold;"); + else if (activeButton == lsvBlankBtn) activeButton->setStyleSheet("background-color: rgba(0, 0, 0, 255); color: white; font-weight: bold;"); + else if (activeButton == lsvSampleBtn) activeButton->setStyleSheet("background-color: rgba(75, 0, 130, 76); color: white; font-weight: bold;"); + } + + activeButton = btn; + + if (blinking) { + blinkTimer->start(); + } else { + blinkTimer->stop(); + if (activeButton) { + // Restore original colors manually + if (activeButton == sweepBtn) activeButton->setStyleSheet("background-color: rgba(173, 216, 230, 76); color: white; font-weight: bold;"); + else if (activeButton == measureBtn) activeButton->setStyleSheet("background-color: rgba(0, 100, 0, 76); color: white; font-weight: bold;"); + else if (activeButton == ampBtn) activeButton->setStyleSheet("background-color: rgba(238, 130, 238, 51); color: white; font-weight: bold;"); + else if (activeButton == lsvBlankBtn) activeButton->setStyleSheet("background-color: rgba(0, 0, 0, 255); color: white; font-weight: bold;"); + else if (activeButton == lsvSampleBtn) activeButton->setStyleSheet("background-color: rgba(75, 0, 130, 76); color: white; font-weight: bold;"); + } + activeButton = nullptr; + } +} + +void MainWindow::onBlinkTimer() { + if (!activeButton) return; + + blinkState = !blinkState; + if (blinkState) { + activeButton->setStyleSheet("background-color: #FF0000; color: white; border: 1px solid #FF4444; font-weight: bold;"); + } else { + activeButton->setStyleSheet("background-color: #880000; color: white; border: 1px solid #AA0000; font-weight: bold;"); + } +} \ No newline at end of file diff --git a/main.c b/main.c index 318b28b..a628ff6 100644 --- a/main.c +++ b/main.c @@ -41,17 +41,31 @@ typedef enum { } AppMode; AppMode CurrentMode = MODE_IDLE; -float LFOSCFreq = 32000.0; // Default, updated by calibration +float LFOSCFreq = 32000.0; // --------------------------------------------------------------------------- // Range / RTIA Management // --------------------------------------------------------------------------- -// Nominal values for the 8 supported ranges -float RtiaCalibrationTable[8] = {200.0, 1000.0, 5000.0, 10000.0, 20000.0, 40000.0, 80000.0, 160000.0}; -uint32_t CurrentRtiaIndex = 0; // Default to 200 Ohm (Index 0) -uint32_t ConfigRtiaVal = 200; // Default Value -uint32_t CurrentLpTiaRf = LPTIARF_20K; // Default LPF +// Full list of 26 supported ranges +float RtiaCalibrationTable[26] = { + 200.0, 1000.0, 2000.0, 3000.0, 4000.0, 6000.0, 8000.0, + 10000.0, 12000.0, 16000.0, 20000.0, 24000.0, 30000.0, 32000.0, + 40000.0, 48000.0, 64000.0, 85000.0, 96000.0, 100000.0, + 120000.0, 128000.0, 160000.0, 196000.0, 256000.0, 512000.0 +}; + +uint32_t CurrentRtiaIndex = 1; // Default 1k +uint32_t ConfigRtiaVal = 1000; +uint32_t CurrentLpTiaRf = LPTIARF_20K; + +// Helper to find index for calibration table +int GetRtiaIndex(uint32_t val) { + for(int i=0; i<26; i++) { + if((uint32_t)RtiaCalibrationTable[i] == val) return i; + } + return 1; // Default 1k +} // Map integer value to HSTIA Enum (Impedance) uint32_t GetHSTIARtia(uint32_t val) { @@ -64,7 +78,8 @@ uint32_t GetHSTIARtia(uint32_t val) { case 40000: return HSTIARTIA_40K; case 80000: return HSTIARTIA_80K; case 160000: return HSTIARTIA_160K; - default: return HSTIARTIA_200; + // Note: HSTIA has fewer options than LPTIA. Map closest or default. + default: return HSTIARTIA_1K; } } @@ -73,31 +88,34 @@ uint32_t GetLPTIARtia(uint32_t val) { switch(val) { case 200: return LPTIARTIA_200R; case 1000: return LPTIARTIA_1K; - case 5000: return LPTIARTIA_4K; + case 2000: return LPTIARTIA_2K; + case 3000: return LPTIARTIA_3K; + case 4000: return LPTIARTIA_4K; + case 6000: return LPTIARTIA_6K; + case 8000: return LPTIARTIA_8K; case 10000: return LPTIARTIA_10K; + case 12000: return LPTIARTIA_12K; + case 16000: return LPTIARTIA_16K; case 20000: return LPTIARTIA_20K; + case 24000: return LPTIARTIA_24K; + case 30000: return LPTIARTIA_30K; + case 32000: return LPTIARTIA_32K; case 40000: return LPTIARTIA_40K; - case 80000: return LPTIARTIA_85K; + case 48000: return LPTIARTIA_48K; + case 64000: return LPTIARTIA_64K; + case 85000: return LPTIARTIA_85K; + case 96000: return LPTIARTIA_96K; + case 100000: return LPTIARTIA_100K; + case 120000: return LPTIARTIA_120K; + case 128000: return LPTIARTIA_128K; case 160000: return LPTIARTIA_160K; + case 196000: return LPTIARTIA_196K; + case 256000: return LPTIARTIA_256K; + case 512000: return LPTIARTIA_512K; default: return LPTIARTIA_1K; } } -// Helper to find index for calibration table -int GetRtiaIndex(uint32_t val) { - switch(val) { - case 200: return 0; - case 1000: return 1; - case 5000: return 2; - case 10000: return 3; - case 20000: return 4; - case 40000: return 5; - case 80000: return 6; - case 160000: return 7; - default: return 0; - } -} - // --------------------------------------------------------------------------- // Platform Interface Implementation // --------------------------------------------------------------------------- @@ -194,7 +212,7 @@ void AD5940AMPStructInit(void) pAMPCfg->SensorBias = 0; pAMPCfg->LptiaRtiaSel = GetLPTIARtia(ConfigRtiaVal); pAMPCfg->LpTiaRl = LPTIARLOAD_10R; - pAMPCfg->LpTiaRf = CurrentLpTiaRf; // Use configured LPF + pAMPCfg->LpTiaRf = CurrentLpTiaRf; pAMPCfg->Vzero = 1100; pAMPCfg->ADCRefVolt = 1.82; } @@ -222,7 +240,7 @@ void AD5940RampStructInit(void) pRampCfg->LPTIARtiaSel = GetLPTIARtia(ConfigRtiaVal); pRampCfg->LPTIARloadSel = LPTIARLOAD_10R; - pRampCfg->LpTiaRf = CurrentLpTiaRf; // Use configured LPF + pRampCfg->LpTiaRf = CurrentLpTiaRf; pRampCfg->AdcPgaGain = ADCPGA_1P5; }