updates for next version

This commit is contained in:
2025-08-19 13:57:14 -07:00
parent 6b50e7f9f5
commit 65e33c8619
3 changed files with 173 additions and 26 deletions

16
changelog.md Normal file
View File

@@ -0,0 +1,16 @@
# Version 1.0.1
- Two new options have been added when clicking on a participant's file.
- First new option is Age which allows the option to specify age on a per-file basis to calculate DPF by using the Scholkmann & Wolf calculation instead of having one DPF for all files. Fixes [Issue 1](https://git.research.dezeeuw.ca/tyler/flares/issues/1). This also allows for proper calculation of DPF for the two seperate wavelengths.
- If no age is set by the user, the code will fall back to the age of 25
- Second new option is Gender to be used in future analysis options (currently not implemented).
- UI elements have been shuffled to make room for the new parameters.
- Removed the process button from being available until a file or project is opened. Fixes [Issue 2](https://git.research.dezeeuw.ca/tyler/flares/issues/2).
- Removed the option for PPF under Haemoglobin Concentration as it is no longer used.
- Added a new category in the options called Other
- Added MAX_WORKERS to the Other category. This is how many files should be processed at once. Fixes [Issue 13](https://git.research.dezeeuw.ca/tyler/flares/issues/13).
# Version 1.0.0
- Initial release.

View File

@@ -42,14 +42,14 @@ from pandas import DataFrame
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from matplotlib.axes import Axes from matplotlib.axes import Axes
from numpy.typing import NDArray from numpy.typing import NDArray
import vtkmodules.util.data_model #import vtkmodules.util.data_model
from numpy import floating, float64 from numpy import floating, float64
from matplotlib.lines import Line2D from matplotlib.lines import Line2D
import matplotlib.colors as mcolors import matplotlib.colors as mcolors
from scipy.stats import ttest_1samp # type: ignore from scipy.stats import ttest_1samp # type: ignore
from matplotlib.figure import Figure from matplotlib.figure import Figure
import statsmodels.formula.api as smf # type: ignore import statsmodels.formula.api as smf # type: ignore
import vtkmodules.util.execution_model #import vtkmodules.util.execution_model
from nilearn.plotting import plot_design_matrix # type: ignore from nilearn.plotting import plot_design_matrix # type: ignore
from scipy.signal import welch, butter, filtfilt # type: ignore from scipy.signal import welch, butter, filtfilt # type: ignore
from matplotlib.colors import LinearSegmentedColormap from matplotlib.colors import LinearSegmentedColormap
@@ -127,7 +127,7 @@ EXCLUDE_CHANNELS: bool
MAX_BAD_CHANNELS: int MAX_BAD_CHANNELS: int
LONG_CHANNEL_THRESH: float LONG_CHANNEL_THRESH: float
PPF: float METADATA: dict
DRIFT_MODEL: str DRIFT_MODEL: str
DURATION_BETWEEN_ACTIVITIES: int DURATION_BETWEEN_ACTIVITIES: int
@@ -223,7 +223,7 @@ REQUIRED_KEYS: dict[str, Any] = {
"EXCLUDE_CHANNELS": bool, "EXCLUDE_CHANNELS": bool,
"MAX_BAD_CHANNELS": int, "MAX_BAD_CHANNELS": int,
"LONG_CHANNEL_THRESH": float, "LONG_CHANNEL_THRESH": float,
"PPF": float, "METADATA": dict,
"DRIFT_MODEL": str, "DRIFT_MODEL": str,
"DURATION_BETWEEN_ACTIVITIES": int, "DURATION_BETWEEN_ACTIVITIES": int,
"HRF_MODEL": str, "HRF_MODEL": str,
@@ -284,8 +284,6 @@ else:
logger = logging.getLogger() logger = logging.getLogger()
class ProcessingError(Exception): class ProcessingError(Exception):
def __init__(self, message: str = "Something went wrong!"): def __init__(self, message: str = "Something went wrong!"):
self.message = message self.message = message
@@ -1615,7 +1613,7 @@ def calculate_optical_density(data: BaseRaw, ID: str) -> tuple[BaseRaw, Figure]:
# STEP 9: Haemoglobin concentration # STEP 9: Haemoglobin concentration
def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str) -> tuple[BaseRaw, Figure]: def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str, file_path: str) -> tuple[BaseRaw, Figure]:
""" """
Calculates haemoglobin concentration from optical density data using the Beer-Lambert law and generates a plot. Calculates haemoglobin concentration from optical density data using the Beer-Lambert law and generates a plot.
@@ -1625,7 +1623,9 @@ def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str)
The data in optical density format. The data in optical density format.
ID : str ID : str
File name of the the snirf file that was loaded. File name of the the snirf file that was loaded.
file_path : str
Entire file path if snirf file that was loaded.
Returns Returns
------- -------
tuple[BaseRaw, Figure] tuple[BaseRaw, Figure]
@@ -1636,7 +1636,7 @@ def calculate_haemoglobin_concentration(optical_density_data: BaseRaw, ID: str)
logger.info("Calculating haemoglobin concentration data...") logger.info("Calculating haemoglobin concentration data...")
# Get the haemoglobin concentration using beer lambert law # Get the haemoglobin concentration using beer lambert law
haemoglobin_concentration_data = beer_lambert_law(optical_density_data, PPF) haemoglobin_concentration_data = beer_lambert_law(optical_density_data, ppf=calculate_dpf(file_path))
logger.info("Creating the figure...") logger.info("Creating the figure...")
fig = cast(Figure, optical_density_data.plot(show=False, n_channels=len(getattr(optical_density_data, "ch_names")), duration=getattr(optical_density_data, "times")[-1]).figure) # type: ignore fig = cast(Figure, optical_density_data.plot(show=False, n_channels=len(getattr(optical_density_data, "ch_names")), duration=getattr(optical_density_data, "times")[-1]).figure) # type: ignore
@@ -1720,10 +1720,10 @@ def calculate_and_apply_negative_correlation_enhancement(haemoglobin_concentrati
def calculate_and_apply_short_channel_correction(optical_density_data: BaseRaw) -> dict[str, list[Any] | mne.evoked.EvokedArray]: def calculate_and_apply_short_channel_correction(optical_density_data: BaseRaw, file_path: str) -> dict[str, list[Any] | mne.evoked.EvokedArray]:
od_corrected = short_channel_regression(optical_density_data, SHORT_CHANNEL_THRESH) od_corrected = short_channel_regression(optical_density_data, SHORT_CHANNEL_THRESH)
haemoglobin_concentration_data = beer_lambert_law(od_corrected, PPF) haemoglobin_concentration_data = beer_lambert_law(od_corrected, ppf=calculate_dpf(file_path))
events, _ = mne.events_from_annotations(haemoglobin_concentration_data, event_id={"Reach": 1, "Start of Rest": 2}, verbose=VERBOSITY) # type: ignore events, _ = mne.events_from_annotations(haemoglobin_concentration_data, event_id={"Reach": 1, "Start of Rest": 2}, verbose=VERBOSITY) # type: ignore
event_dict = {"Reach": 1, "Start of Rest": 2} event_dict = {"Reach": 1, "Start of Rest": 2}
@@ -1917,6 +1917,33 @@ def run_GLM_analysis(data: BaseRaw, design_matrix: DataFrame) -> RegressionResul
return glm_est return glm_est
def calculate_dpf(file_path):
# order is hbo / hbr
import h5py
with h5py.File(file_path, 'r') as f:
wavelengths = f['/nirs/probe/wavelengths'][:]
print("Wavelengths (nm):", wavelengths)
wavelengths = sorted(wavelengths, reverse=True)
data = METADATA.get(file_path)
if data is None:
age = 25
else:
age = data['Age']
logger.info(age)
age = float(age)
a = 223.3
b = 0.05624
c = 0.8493
d = -5.723e-7
e = 0.001245
f = -0.9025
dpf = []
for w in wavelengths:
logger.info(w)
dpf.append(a + b * (age**c) + d* (w**3) + e * (w**2) + f*w)
logger.info(dpf)
return dpf
def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0, progress_callback=None) -> tuple[BaseRaw, BaseRaw, DataFrame, DataFrame, DataFrame, DataFrame, dict[str, Figure], str, bool, bool]: def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0, progress_callback=None) -> tuple[BaseRaw, BaseRaw, DataFrame, DataFrame, DataFrame, DataFrame, dict[str, Figure], str, bool, bool]:
""" """
@@ -1971,6 +1998,12 @@ def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0,
# Initalize the participants full layout to be the current data regardless if it will be updated later # Initalize the participants full layout to be the current data regardless if it will be updated later
raw_full_layout = data raw_full_layout = data
logger.info(file_path)
logger.info(ID)
logger.info(METADATA.get(file_path))
calculate_dpf(file_path)
try: try:
# Did the user want to load new channel positions from an optode file? # Did the user want to load new channel positions from an optode file?
# STEP 2 # STEP 2
@@ -1985,7 +2018,7 @@ def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0,
# but i shouldnt need to do od and bll just check the last three numbers # but i shouldnt need to do od and bll just check the last three numbers
temp = data.copy() temp = data.copy()
temp_od = cast(BaseRaw, optical_density(temp, verbose=VERBOSITY)) temp_od = cast(BaseRaw, optical_density(temp, verbose=VERBOSITY))
raw_full_layout = beer_lambert_law(temp_od, ppf=PPF) raw_full_layout = beer_lambert_law(temp_od, ppf=calculate_dpf(file_path))
# If specified, apply TDDR to the data # If specified, apply TDDR to the data
# STEP 3 # STEP 3
@@ -2082,11 +2115,11 @@ def individual_GLM_analysis(file_path: str, ID: str, stim_duration: float = 5.0,
if SHORT_CHANNEL: if SHORT_CHANNEL:
short_chans_od = cast(BaseRaw, optical_density(short_chans)) short_chans_od = cast(BaseRaw, optical_density(short_chans))
data_recombined = cast(BaseRaw, data.copy().add_channels([short_chans_od])) # type: ignore data_recombined = cast(BaseRaw, data.copy().add_channels([short_chans_od])) # type: ignore
evoked_dict_corr = calculate_and_apply_short_channel_correction(data_recombined.copy()) evoked_dict_corr = calculate_and_apply_short_channel_correction(data_recombined.copy(), file_path)
# Calculate the haemoglobin concentration # Calculate the haemoglobin concentration
# STEP 9 # STEP 9
data, fig = calculate_haemoglobin_concentration(data, ID) data, fig = calculate_haemoglobin_concentration(data, ID, file_path)
order_of_operations += " + Haemoglobin Concentration" order_of_operations += " + Haemoglobin Concentration"
fig_dict['HaemoglobinConcentration'] = fig fig_dict['HaemoglobinConcentration'] = fig
if progress_callback: progress_callback(9) if progress_callback: progress_callback(9)

122
main.py
View File

@@ -27,7 +27,7 @@ import psutil
import requests import requests
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout, QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy, QGroupBox
) )
from PySide6.QtCore import QThread, Signal, Qt, QTimer from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator
@@ -114,7 +114,7 @@ SECTIONS = [
{ {
"title": "Haemoglobin Concentration", "title": "Haemoglobin Concentration",
"params": [ "params": [
{"name": "PPF", "default": 0.1, "type": float, "help": "Partial Pathlength Factor."}, # Intentionally empty (TODO)
] ]
}, },
{ {
@@ -128,7 +128,7 @@ SECTIONS = [
{ {
"title": "General Linear Model", "title": "General Linear Model",
"params": [ "params": [
{"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for processing."}, {"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for GLM processing."},
] ]
}, },
{ {
@@ -137,6 +137,12 @@ SECTIONS = [
# Intentionally empty (TODO) # Intentionally empty (TODO)
] ]
}, },
{
"title": "Other",
"params": [
{"name": "MAX_WORKERS", "default": 4, "type": int, "help": "Number of files to process at once."},
]
},
] ]
@@ -403,7 +409,7 @@ class ProgressBubble(QWidget):
self.setLayout(self.layout) self.setLayout(self.layout)
# Store the file path # Store the file path
self.file_path = file_path self.file_path = os.path.normpath(file_path)
self.current_step = 0 self.current_step = 0
# Make the bubble clickable # Make the bubble clickable
@@ -619,6 +625,10 @@ class MainApplication(QMainWindow):
self.platform_suffix = "-" + PLATFORM_NAME self.platform_suffix = "-" + PLATFORM_NAME
self.pending_update_version = None self.pending_update_version = None
self.pending_update_path = None self.pending_update_path = None
self.file_metadata = {}
self.current_file = None
# Start local pending update check thread # Start local pending update check thread
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix) self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix)
@@ -642,11 +652,51 @@ class MainApplication(QMainWindow):
left_container.setLayout(left_layout) left_container.setLayout(left_layout)
left_container.setMinimumWidth(300) left_container.setMinimumWidth(300)
# Top left widget (for demo, use QTextEdit) top_left_container = QGroupBox() # Optional: add a title inside parentheses
top_left_container.setTitle("File information") # Optional visible title
top_left_container.setStyleSheet("QGroupBox { font-weight: bold; }") # Style if needed
top_left_container.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
top_left_layout = QHBoxLayout()
top_left_container.setLayout(top_left_layout)
# QTextEdit with fixed height, but only 80% width
self.top_left_widget = QTextEdit() self.top_left_widget = QTextEdit()
self.top_left_widget.setReadOnly(True) self.top_left_widget.setReadOnly(True)
self.top_left_widget.setPlaceholderText("Click a file below to get started! No files below? Open one using File > Open!") self.top_left_widget.setPlaceholderText("Click a file below to get started! No files below? Open one using File > Open!")
self.top_left_widget.setFixedHeight(250)
# Add QTextEdit to the layout with a stretch factor
top_left_layout.addWidget(self.top_left_widget, stretch=4) # 80%
# Create a vertical box layout for the right 20%
self.right_column_widget = QWidget()
right_column_layout = QVBoxLayout()
self.right_column_widget.setLayout(right_column_layout)
self.meta_fields = {
"Age": QLineEdit(),
"Gender": QLineEdit(),
}
# Inside your top-left container's right column layout:
for key, field in self.meta_fields.items():
label = QLabel(key.capitalize())
field.setPlaceholderText(f"Enter {key}")
right_column_layout.addWidget(label)
right_column_layout.addWidget(field)
right_column_layout.addStretch() # Push fields to top
self.right_column_widget.hide()
# Add right column widget to the top-left layout (takes 20% width)
top_left_layout.addWidget(self.right_column_widget, stretch=1)
# Add top_left_container to the main left_layout
left_layout.addWidget(top_left_container)
# Bottom left: the bubbles inside a scroll area # Bottom left: the bubbles inside a scroll area
self.bubble_container = QWidget() self.bubble_container = QWidget()
@@ -660,7 +710,6 @@ class MainApplication(QMainWindow):
self.scroll_area.setMinimumHeight(300) self.scroll_area.setMinimumHeight(300)
# Add top left and bottom left to left layout # Add top left and bottom left to left layout
left_layout.addWidget(self.top_left_widget)
left_layout.addWidget(self.scroll_area) left_layout.addWidget(self.scroll_area)
self.progress_update_signal.connect(self.update_file_progress) self.progress_update_signal.connect(self.update_file_progress)
@@ -713,6 +762,7 @@ class MainApplication(QMainWindow):
self.button2.setMinimumSize(100, 40) self.button2.setMinimumSize(100, 40)
self.button3.setMinimumSize(100, 40) self.button3.setMinimumSize(100, 40)
self.button1.setVisible(False)
self.button3.setVisible(False) self.button3.setVisible(False)
self.button1.clicked.connect(self.on_run_task) self.button1.clicked.connect(self.on_run_task)
@@ -828,6 +878,8 @@ class MainApplication(QMainWindow):
def clear_all(self): def clear_all(self):
self.right_column_widget.hide()
# Clear the bubble layout # Clear the bubble layout
while self.bubble_layout.count(): while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0) item = self.bubble_layout.takeAt(0)
@@ -846,6 +898,7 @@ class MainApplication(QMainWindow):
self.all_figures = None self.all_figures = None
# Reset any visible UI elements # Reset any visible UI elements
self.button1.setVisible(False)
self.button3.setVisible(False) self.button3.setVisible(False)
self.top_left_widget.clear() self.top_left_widget.clear()
@@ -889,6 +942,8 @@ class MainApplication(QMainWindow):
if file_path: if file_path:
self.selected_path = file_path # store the file path self.selected_path = file_path # store the file path
self.show_files_as_bubbles(file_path) self.show_files_as_bubbles(file_path)
self.button1.setVisible(True)
def open_folder_dialog(self): def open_folder_dialog(self):
folder_path = QFileDialog.getExistingDirectory( folder_path = QFileDialog.getExistingDirectory(
@@ -897,6 +952,8 @@ class MainApplication(QMainWindow):
if folder_path: if folder_path:
self.selected_path = folder_path # store the folder path self.selected_path = folder_path # store the folder path
self.show_files_as_bubbles(folder_path) self.show_files_as_bubbles(folder_path)
self.button1.setVisible(True)
def open_multiple_folders_dialog(self): def open_multiple_folders_dialog(self):
@@ -921,6 +978,8 @@ class MainApplication(QMainWindow):
) )
if more == QMessageBox.StandardButton.No: if more == QMessageBox.StandardButton.No:
break break
self.button1.setVisible(True)
def save_project(self): def save_project(self):
@@ -990,7 +1049,8 @@ class MainApplication(QMainWindow):
self.show_files_as_bubbles_from_list(data["file_list"], data.get("progress_states", {}), filename) self.show_files_as_bubbles_from_list(data["file_list"], data.get("progress_states", {}), filename)
# Re-enable the "Viewer" button # Re-enable the buttons
self.button1.setVisible(True)
self.button3.setVisible(True) self.button3.setVisible(True)
QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}") QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}")
@@ -1089,6 +1149,10 @@ class MainApplication(QMainWindow):
def on_bubble_clicked(self, bubble): def on_bubble_clicked(self, bubble):
# show age / gender
self.right_column_widget.show()
file_path = bubble.file_path file_path = bubble.file_path
if not os.path.exists(file_path): if not os.path.exists(file_path):
self.top_left_widget.setText("File not found.") self.top_left_widget.setText("File not found.")
@@ -1121,15 +1185,51 @@ class MainApplication(QMainWindow):
# info += f" {k}: {v}\n" # info += f" {k}: {v}\n"
self.top_left_widget.setText(info) self.top_left_widget.setText(info)
clicked_bubble = self.sender()
file_path = clicked_bubble.file_path
# Save current file's metadata
if self.current_file:
self.save_metadata(self.current_file)
# Update current file
self.current_file = file_path
# Load new file's metadata into the fields
metadata = self.file_metadata.get(file_path, {})
for key, field in self.meta_fields.items():
field.blockSignals(True)
field.setText(metadata.get(key, ""))
field.blockSignals(False)
def placeholder(self): def placeholder(self):
QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.") QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.")
def save_metadata(self, file_path):
if not file_path:
return
self.file_metadata[file_path] = {
key: field.text()
for key, field in self.meta_fields.items()
}
def get_all_metadata(self):
# First, make sure current file's edits are saved
if self.current_file:
self.save_metadata(self.current_file)
return self.file_metadata
'''MODULE FILE''' '''MODULE FILE'''
def on_run_task(self): def on_run_task(self):
all_metadata = self.get_all_metadata()
for file_path, data in all_metadata.items():
print(f"{file_path}: {data}")
collected_data = {} collected_data = {}
@@ -1164,7 +1264,6 @@ class MainApplication(QMainWindow):
collected_data["SNIRF_SUBFOLDERS"] = [Path(p).name for p in self.selected_paths] collected_data["SNIRF_SUBFOLDERS"] = [Path(p).name for p in self.selected_paths]
collected_data["STIM_DURATION"] = [0 for _ in self.selected_paths] collected_data["STIM_DURATION"] = [0 for _ in self.selected_paths]
elif hasattr(self, "selected_path") and self.selected_path: elif hasattr(self, "selected_path") and self.selected_path:
# Handle single folder # Handle single folder
selected_path = Path(self.selected_path) selected_path = Path(self.selected_path)
@@ -1176,10 +1275,9 @@ class MainApplication(QMainWindow):
# No folder selected - handle gracefully or raise error # No folder selected - handle gracefully or raise error
raise ValueError("No folder(s) selected") raise ValueError("No folder(s) selected")
collected_data["METADATA"] = all_metadata
collected_data["HRF_MODEL"] = 'fir' collected_data["HRF_MODEL"] = 'fir'
collected_data["MAX_WORKERS"] = 12
collected_data["FORCE_DROP_CHANNELS"] = [] collected_data["FORCE_DROP_CHANNELS"] = []
collected_data["TARGET_ACTIVITY"] = "Reach" collected_data["TARGET_ACTIVITY"] = "Reach"