diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml new file mode 100644 index 0000000..cceed34 --- /dev/null +++ b/.github/workflows/build-and-publish.yml @@ -0,0 +1,187 @@ +name: Build and Publish + +on: + push: + branches: [main, master, Pyber_v0.15] + tags: ["v*"] + pull_request: + branches: [main, master, Pyber_v0.15] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-windows: + runs-on: windows-latest + defaults: + run: + shell: bash -el {0} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Conda environment + uses: conda-incubator/setup-miniconda@v3 + with: + activate-environment: pyBer + auto-activate-base: false + environment-file: environment.yml + channels: conda-forge + channel-priority: strict + use-mamba: true + + - name: Show build environment + run: | + python --version + python -m pip --version + python -c "import os, sys; print('CONDA_PREFIX=', os.environ.get('CONDA_PREFIX')); print('PYTHON=', sys.executable)" + + - name: Build Windows icon from assets + run: | + python -m pip install --upgrade pillow + mkdir -p build + python - <<'PY' + from pathlib import Path + from PIL import Image + + src = Path("assets/pyBer_logo_big.png") + dst = Path("build/pyBer_logo_big.ico") + if not src.exists(): + raise SystemExit(f"Missing icon source: {src}") + + img = Image.open(src).convert("RGBA") + sizes = [(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)] + img.save(dst, format="ICO", sizes=sizes) + print(f"Wrote {dst}") + PY + + - name: Build with PyInstaller (last known good method) + run: | + BIN_DIR="$CONDA_PREFIX/Library/bin" + DLL_PATTERNS=( + "hdf5*.dll" + "zlib*.dll" + "blosc*.dll" + "libblosc2*.dll" + "libpng16*.dll" + "freetype*.dll" + "liblapack*.dll" + "libblas*.dll" + "libcblas*.dll" + "mkl_rt*.dll" + "mkl_core*.dll" + "mkl_intel_thread*.dll" + "mkl_intel_lp64*.dll" + "mkl_sequential*.dll" + "libmmd*.dll" + "libifcoremd*.dll" + "libifportmd*.dll" + "libiomp5md*.dll" + "libimalloc*.dll" + "svml_dispmd*.dll" + ) + + ADD_ARGS=() + declare -A SEEN_DLLS + shopt -s nullglob + for pattern in "${DLL_PATTERNS[@]}"; do + matches=("$BIN_DIR"/$pattern) + if [[ ${#matches[@]} -eq 0 ]]; then + echo "No matches for pattern: $pattern" + continue + fi + for dll_path in "${matches[@]}"; do + dll_name="$(basename "$dll_path")" + # Optional cluster/MPI BLACS DLLs are not required for local scipy use. + if [[ "$dll_name" =~ ^mkl_blacs_.*(intelmpi|msmpi).*\.dll$ ]]; then + echo "Skipping optional BLACS runtime: $dll_name" + continue + fi + if [[ -n "${SEEN_DLLS[$dll_name]:-}" ]]; then + continue + fi + echo "Bundling $dll_name" + ADD_ARGS+=(--add-binary "$dll_path;.") + SEEN_DLLS["$dll_name"]=1 + done + done + shopt -u nullglob + + python - <<'PY' + import numpy, scipy, h5py, sklearn + print("Import smoke test OK") + print("numpy:", numpy.__version__) + print("scipy:", scipy.__version__) + print("h5py:", h5py.__version__) + print("sklearn:", sklearn.__version__) + PY + + python -m PyInstaller \ + --noconfirm \ + --clean \ + --onefile \ + --windowed \ + --icon build/pyBer_logo_big.ico \ + --add-data "assets;assets" \ + --collect-binaries numpy \ + --collect-binaries scipy \ + --collect-binaries h5py \ + --collect-binaries sklearn \ + --exclude-module pyqtgraph.opengl \ + --exclude-module OpenGL \ + --name pyBer \ + "${ADD_ARGS[@]}" \ + pyBer/main.py + + - name: Smoke test built EXE (switch to Postprocessing tab) + run: | + export PYBER_SMOKE_TEST=1 + ./dist/pyBer.exe + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: pyber-windows-exe + path: dist/pyBer.exe + if-no-files-found: error + + publish: + needs: [build-windows] + if: | + needs.build-windows.result == 'success' && ( + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/Pyber_v0.15' || startsWith(github.ref, 'refs/tags/')) + ) + ) + runs-on: ubuntu-latest + steps: + - name: Download Windows Artifact + uses: actions/download-artifact@v4 + with: + name: pyber-windows-exe + path: ./publish + + - name: Normalize Release Asset Name + run: | + set -euo pipefail + exe_path="$(find ./publish -type f -name '*.exe' | head -n 1 || true)" + if [[ -z "$exe_path" ]]; then + echo "No EXE found in downloaded artifact" + find ./publish -maxdepth 4 -type f -print || true + exit 1 + fi + cp "$exe_path" ./publish/pyBer-windows.exe + + - name: Create Release + uses: ncipollo/release-action@v1 + with: + artifacts: "./publish/pyBer-windows.exe" + token: ${{ secrets.GITHUB_TOKEN }} + generateReleaseNotes: true + allowUpdates: true + tag: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'latest' }} + name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'Latest Build' }} + prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da91c77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +pyBer.spec +build/pyBer/Analysis-00.toc +build/pyBer/base_library.zip +build/pyBer/EXE-00.toc +build/pyBer/PKG-00.toc +build/pyBer/pyBer.pkg +build/pyBer/PYZ-00.pyz +build/pyBer/PYZ-00.toc +build/pyBer/warn-pyBer.txt +build/pyBer/xref-pyBer.html +build/pyBer/localpycs/pyimod01_archive.pyc +build/pyBer/localpycs/pyimod02_importers.pyc +build/pyBer/localpycs/pyimod03_ctypes.pyc +build/pyBer/localpycs/pyimod04_pywin32.pyc +build/pyBer/localpycs/struct.pyc +dist/pyBer.exe +pyBer/__pycache__/gui_postprocessing.cpython-38.pyc +pyBer/__pycache__/gui_postprocessing.cpython-38.pyc +pyBer/__pycache__/gui_postprocessing.cpython-38.pyc +pyBer/__pycache__/gui_postprocessing.cpython-38.pyc +pyBer/__pycache__/gui_postprocessing.cpython-311.pyc +pyBer/__pycache__/main.cpython-311.pyc +pyBer/__pycache__/gui_postprocessing.cpython-311.pyc +pyBer/__pycache__/gui_postprocessing.cpython-38.pyc +pyBer/__pycache__/gui_postprocessing.cpython-311.pyc +pyBer/__pycache__ +panel_layout.json \ No newline at end of file diff --git a/README.md b/README.md index 659bca2..ec8485b 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,12 @@ The GUI exposes seven explicit output definitions: - **Lasso**: sparse regression (requires `scikit-learn`) - **RLM (HuberT)**: robust linear model via IRLS + Huber weighting (no extra dependency) -### Export -- Export processed output to: - - CSV (`time`, `output`, optional `dio`) - - HDF5 with raw, baseline, and metadata fields -- Drag-and-drop support for preprocessing and post-processing files. +### Export +- Export processed output to: + - CSV with configurable fields (`time` always included; raw/isobestic/output/DIO selectable) + - HDF5 with configurable raw/output/DIO/baseline datasets plus metadata +- Export field selection is saved and restored through the preprocessing configuration file. +- Drag-and-drop support for preprocessing and post-processing files. --- diff --git a/assets/pyBer.ico b/assets/pyBer.ico new file mode 100644 index 0000000..57b8f8e Binary files /dev/null and b/assets/pyBer.ico differ diff --git a/environment.yml b/environment.yml index 163423f..3859ef7 100644 --- a/environment.yml +++ b/environment.yml @@ -1,37 +1,31 @@ -name: pyBer -channels: - - conda-forge - - defaults - -dependencies: - # Core interpreter - - python=3.11 - - # Numeric + signal processing - - numpy>=1.24 - - scipy>=1.10 - - pyqtgraph>=0.13 - # File IO (Doric H5) - - h5py>=3.9 - - # Baseline correction - - pybaselines>=1.1 - - # GUI - - pyside6>=6.6 - - - - scikit-learn>=1.3 - - # Quality-of-life / packaging - - pip - - setuptools - - wheel - - - - matplotlib>=3.8 +name: pyBer +channels: + - conda-forge +dependencies: + # Core + - python=3.11 + # Numeric and signal processing + - libblas=*=*openblas + - liblapack=*=*openblas + - libcblas=*=*openblas + - numpy>=1.24 + - scipy>=1.10 + - h5py>=3.9 + - scikit-learn>=1.3 + - pybaselines>=1.1 - pandas>=2.0 + - openpyxl>=3.1 + + # GUI + - pyside6>=6.6 + - pyqtgraph>=0.13 + - matplotlib>=3.8 + + # Quality-of-life / packaging + - pip + - setuptools + - wheel - pyinstaller>=6.0 - pyinstaller-hooks-contrib>=2024.0 diff --git a/preprocessing_config.json b/preprocessing_config.json new file mode 100644 index 0000000..3460fea --- /dev/null +++ b/preprocessing_config.json @@ -0,0 +1,28 @@ +{ + "artifact_detection_enabled": true, + "artifact_overlay_visible": true, + "filtering_enabled": true, + "parameters": { + "artifact_detection_enabled": true, + "artifact_mode": "Adaptive MAD (windowed)", + "mad_k": 25.0, + "adaptive_window_s": 1.0, + "artifact_pad_s": 0.5, + "lowpass_hz": 2.1, + "filter_order": 1, + "target_fs_hz": 120.0, + "baseline_method": "arpls", + "baseline_lambda": 100000000000.0, + "baseline_diff_order": 2, + "baseline_max_iter": 50, + "baseline_tol": 0.001, + "asls_p": 0.01, + "output_mode": "zscore (motion corrected with fitted ref)", + "invert_polarity": false, + "reference_fit": "OLS (recommended)", + "lasso_alpha": 0.001, + "rlm_huber_t": 1.345, + "rlm_max_iter": 50, + "rlm_tol": 1e-06 + } +} \ No newline at end of file diff --git a/pyBer/__pycache__/analysis_core.cpython-311.pyc b/pyBer/__pycache__/analysis_core.cpython-311.pyc index ec75647..dcc83cf 100644 Binary files a/pyBer/__pycache__/analysis_core.cpython-311.pyc and b/pyBer/__pycache__/analysis_core.cpython-311.pyc differ diff --git a/pyBer/__pycache__/analysis_core.cpython-38.pyc b/pyBer/__pycache__/analysis_core.cpython-38.pyc index e817050..97f9bc2 100644 Binary files a/pyBer/__pycache__/analysis_core.cpython-38.pyc and b/pyBer/__pycache__/analysis_core.cpython-38.pyc differ diff --git a/pyBer/__pycache__/gui_postprocessing.cpython-311.pyc b/pyBer/__pycache__/gui_postprocessing.cpython-311.pyc index ec368b3..a6f58fb 100644 Binary files a/pyBer/__pycache__/gui_postprocessing.cpython-311.pyc and b/pyBer/__pycache__/gui_postprocessing.cpython-311.pyc differ diff --git a/pyBer/__pycache__/gui_postprocessing.cpython-38.pyc b/pyBer/__pycache__/gui_postprocessing.cpython-38.pyc index d496bd5..046c78f 100644 Binary files a/pyBer/__pycache__/gui_postprocessing.cpython-38.pyc and b/pyBer/__pycache__/gui_postprocessing.cpython-38.pyc differ diff --git a/pyBer/__pycache__/gui_preprocessing.cpython-311.pyc b/pyBer/__pycache__/gui_preprocessing.cpython-311.pyc index 051acc5..ee881c7 100644 Binary files a/pyBer/__pycache__/gui_preprocessing.cpython-311.pyc and b/pyBer/__pycache__/gui_preprocessing.cpython-311.pyc differ diff --git a/pyBer/__pycache__/gui_preprocessing.cpython-38.pyc b/pyBer/__pycache__/gui_preprocessing.cpython-38.pyc index 0689601..ba1c94b 100644 Binary files a/pyBer/__pycache__/gui_preprocessing.cpython-38.pyc and b/pyBer/__pycache__/gui_preprocessing.cpython-38.pyc differ diff --git a/pyBer/__pycache__/main.cpython-311.pyc b/pyBer/__pycache__/main.cpython-311.pyc index 5c3973d..03b44a5 100644 Binary files a/pyBer/__pycache__/main.cpython-311.pyc and b/pyBer/__pycache__/main.cpython-311.pyc differ diff --git a/pyBer/__pycache__/styles.cpython-311.pyc b/pyBer/__pycache__/styles.cpython-311.pyc index 8d9354c..634986b 100644 Binary files a/pyBer/__pycache__/styles.cpython-311.pyc and b/pyBer/__pycache__/styles.cpython-311.pyc differ diff --git a/pyBer/__pycache__/styles.cpython-38.pyc b/pyBer/__pycache__/styles.cpython-38.pyc index f81ca14..0381106 100644 Binary files a/pyBer/__pycache__/styles.cpython-38.pyc and b/pyBer/__pycache__/styles.cpython-38.pyc differ diff --git a/pyBer/analysis_core.py b/pyBer/analysis_core.py index 3f45ea5..6c9b0b3 100644 --- a/pyBer/analysis_core.py +++ b/pyBer/analysis_core.py @@ -4,13 +4,15 @@ import os import re import time -from dataclasses import dataclass +import json +from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple import numpy as np import h5py -from scipy.signal import butter, sosfiltfilt, resample_poly +from scipy.signal import butter, sosfiltfilt, resample_poly, savgol_filter +from scipy.ndimage import uniform_filter1d, median_filter from PySide6 import QtCore # for QRunnable signals # Optional: Lasso (requires scikit-learn). If unavailable, we fall back to OLS. @@ -42,7 +44,13 @@ "RLM (HuberT)", ] -# Output modes (user requested: 7 clear options) +SMOOTHING_METHODS = [ + "Savitzky-Golay", + "Moving average", + "Moving median", +] + +# Output modes OUTPUT_MODES = [ # 1) dFF (non motion corrected) "dFF (non motion corrected)", @@ -58,6 +66,8 @@ "dFF (motion corrected with fitted ref)", # 7) zscore of that fitted-ref dFF "zscore (motion corrected with fitted ref)", + # 8) raw signal (processed 465 trace after artifact handling/filtering/resampling) + "Raw signal (465)", ] @@ -77,6 +87,10 @@ class ProcessingParams: # ------------------------- lowpass_hz: float = 12.0 filter_order: int = 3 + smoothing_enabled: bool = False + smoothing_method: str = "Savitzky-Golay" + smoothing_window_s: float = 0.200 + smoothing_polyorder: int = 2 # ------------------------- # Decimation / resampling @@ -140,6 +154,9 @@ class LoadedTrial: trigger_time: Optional[np.ndarray] = None trigger: Optional[np.ndarray] = None trigger_name: str = "" + # Support for multiple triggers (DIO/AOUT) + triggers: Dict[str, np.ndarray] = field(default_factory=dict) + trigger_times: Dict[str, np.ndarray] = field(default_factory=dict) @dataclass @@ -151,8 +168,10 @@ class LoadedDoricFile: reference_by_channel: Dict[str, np.ndarray] digital_time: Optional[np.ndarray] digital_by_name: Dict[str, np.ndarray] + trigger_time_by_name: Dict[str, np.ndarray] + trigger_by_name: Dict[str, np.ndarray] - def make_trial(self, channel: str, trigger_name: Optional[str] = None) -> LoadedTrial: + def make_trial(self, channel: str, trigger_name: Optional[str] = None, trigger_names: Optional[List[str]] = None) -> LoadedTrial: t = self.time_by_channel[channel] sig = self.signal_by_channel[channel] ref = self.reference_by_channel[channel] @@ -160,17 +179,54 @@ def make_trial(self, channel: str, trigger_name: Optional[str] = None) -> Loaded trig_t = None trig = None trig_name = trigger_name or "" + all_triggers = {} + all_trigger_times = {} if trigger_name: - if self.digital_time is not None and trigger_name in self.digital_by_name: - trig_t = self.digital_time - trig = self.digital_by_name[trigger_name] - - # Align analog signals to DIO time base (so DIO overlays correctly). + if trigger_name in self.trigger_by_name: + trig = np.asarray(self.trigger_by_name[trigger_name], float) + trig_t = self.trigger_time_by_name.get(trigger_name, None) + if trig_t is not None: + trig_t = np.asarray(trig_t, float) + elif self.digital_time is not None and trigger_name in self.digital_by_name: + trig_t = np.asarray(self.digital_time, float) + elif trig.size == t.size: + trig_t = np.asarray(t, float) + + if trig_t is not None and trig_t.size and trig.size: + n = min(trig_t.size, trig.size) + trig_t = trig_t[:n] + trig = trig[:n] + + # Align analog signals to selected trigger time base for overlays/event alignment. if trig_t is not None and trig_t.size and t.size and trig_t.size != t.size: sig = np.interp(trig_t, t, sig) ref = np.interp(trig_t, t, ref) t = trig_t + elif self.digital_time is not None and trigger_name in self.digital_by_name: + # Backward compatibility for sessions loaded before trigger map support. + trig_t = self.digital_time + trig = self.digital_by_name[trigger_name] + + # Multiple triggers (for export) + names_to_collect = list(trigger_names) if trigger_names else [] + if trigger_name and trigger_name not in names_to_collect: + names_to_collect.append(trigger_name) + + for name in names_to_collect: + if name in self.trigger_by_name: + val = np.asarray(self.trigger_by_name[name], float) + vt = self.trigger_time_by_name.get(name, None) + if vt is not None: + vt = np.asarray(vt, float) + elif self.digital_time is not None and name in self.digital_by_name: + vt = np.asarray(self.digital_time, float) + elif val.size == t.size: + vt = np.asarray(t, float) + + if vt is not None: + all_triggers[name] = val + all_trigger_times[name] = vt fs = 1.0 / float(np.nanmedian(np.diff(t))) if t.size > 2 else np.nan @@ -184,6 +240,8 @@ def make_trial(self, channel: str, trigger_name: Optional[str] = None) -> Loaded trigger_time=np.asarray(trig_t, float) if trig_t is not None else None, trigger=np.asarray(trig, float) if trig is not None else None, trigger_name=trig_name, + triggers=all_triggers, + trigger_times=all_trigger_times, ) @@ -200,9 +258,11 @@ class ProcessedTrial: raw_thr_hi: Optional[np.ndarray] = None raw_thr_lo: Optional[np.ndarray] = None - # Optional DIO aligned to processed time + # Optional analog/digital trigger channel aligned to processed time dio: Optional[np.ndarray] = None dio_name: str = "" + # Support for multiple triggers + triggers: Dict[str, np.ndarray] = field(default_factory=dict) # Processing intermediates sig_f: Optional[np.ndarray] = None @@ -223,6 +283,39 @@ class ProcessedTrial: fs_used: float = np.nan +@dataclass +class ExportSelection: + raw: bool = True + isobestic: bool = True + output: bool = True + dio: bool = True + baseline_sig: bool = True + baseline_ref: bool = True + + def to_dict(self) -> Dict[str, bool]: + return { + "raw": bool(self.raw), + "isobestic": bool(self.isobestic), + "output": bool(self.output), + "dio": bool(self.dio), + "baseline_sig": bool(self.baseline_sig), + "baseline_ref": bool(self.baseline_ref), + } + + @classmethod + def from_dict(cls, data: Optional[Dict[str, object]]) -> "ExportSelection": + if not isinstance(data, dict): + return cls() + return cls( + raw=bool(data.get("raw", True)), + isobestic=bool(data.get("isobestic", True)), + output=bool(data.get("output", True)), + dio=bool(data.get("dio", True)), + baseline_sig=bool(data.get("baseline_sig", True)), + baseline_ref=bool(data.get("baseline_ref", True)), + ) + + # ============================================================================= # Export helpers # ============================================================================= @@ -256,6 +349,8 @@ def output_label_type(label: str) -> str: return "z-score" if "dff" in lab: return "dFF" + if "raw signal" in lab or lab.startswith("raw"): + return "raw_signal" return "output" @@ -263,9 +358,11 @@ def export_processed_csv( path: str, processed: ProcessedTrial, metadata: Optional[Dict[str, str]] = None, + selection: Optional[ExportSelection] = None, ) -> None: import csv + selection = selection if isinstance(selection, ExportSelection) else ExportSelection() t = np.asarray(processed.time, float) out = np.asarray(processed.output if processed.output is not None else np.full_like(t, np.nan), float) raw = np.asarray(processed.raw_signal if processed.raw_signal is not None else np.full_like(t, np.nan), float) @@ -276,44 +373,54 @@ def export_processed_csv( iso = np.full_like(t, np.nan) dio = None - if processed.dio is not None and processed.dio.size == t.size: + if selection.dio and processed.dio is not None and processed.dio.size == t.size: dio = np.asarray(processed.dio, float) out_col = output_label_type(processed.output_label) with open(path, "w", newline="") as f: w = csv.writer(f) - if processed.output_context: - w.writerow([f"# output_context: {processed.output_context}"]) if metadata: for k, v in metadata.items(): w.writerow([f"# {k}: {v}"]) - if dio is None: - w.writerow(["time", "raw", "isobestic", out_col]) - for i in range(t.size): - w.writerow([ - float(t[i]), - float(raw[i]) if np.isfinite(raw[i]) else np.nan, - float(iso[i]) if np.isfinite(iso[i]) else np.nan, - float(out[i]) if np.isfinite(out[i]) else np.nan, - ]) - else: - w.writerow(["time", "raw", "isobestic", out_col, "dio"]) - for i in range(t.size): - w.writerow([ - float(t[i]), - float(raw[i]) if np.isfinite(raw[i]) else np.nan, - float(iso[i]) if np.isfinite(iso[i]) else np.nan, - float(out[i]) if np.isfinite(out[i]) else np.nan, - float(dio[i]) if np.isfinite(dio[i]) else np.nan, - ]) - - -def export_processed_h5(path: str, processed: ProcessedTrial, metadata: Optional[Dict[str, str]] = None) -> None: + columns = [("time", t)] + if selection.raw: + columns.append(("raw", raw)) + if selection.isobestic: + columns.append(("isobestic", iso)) + if selection.output: + columns.append((out_col, out)) + + # Primary trigger name (e.g. "DIO01") + primary_name = str(processed.dio_name) if processed.dio_name else "dio" + if dio is not None: + columns.append((primary_name, dio)) + + # Add additional triggers + if selection.dio and hasattr(processed, "triggers") and processed.triggers: + for name, val in processed.triggers.items(): + if name != processed.dio_name: # Avoid duplicates + columns.append((name, val)) + + w.writerow([name for name, _ in columns]) + for i in range(t.size): + row = [] + for _, values in columns: + v = float(values[i]) + row.append(v if np.isfinite(v) else np.nan) + w.writerow(row) + + +def export_processed_h5( + path: str, + processed: ProcessedTrial, + metadata: Optional[Dict[str, str]] = None, + selection: Optional[ExportSelection] = None, +) -> None: + selection = selection if isinstance(selection, ExportSelection) else ExportSelection() with h5py.File(path, "w") as f: g = f.create_group("data") g.create_dataset("time", data=np.asarray(processed.time, float), compression="gzip") - g.create_dataset("output", data=np.asarray(processed.output, float), compression="gzip") out_type = output_label_type(processed.output_label) g.attrs["output_label"] = str(processed.output_label) g.attrs["output_context"] = str(processed.output_context) @@ -321,28 +428,43 @@ def export_processed_h5(path: str, processed: ProcessedTrial, metadata: Optional g.attrs["fs_actual"] = float(processed.fs_actual) g.attrs["fs_used"] = float(processed.fs_used) g.attrs["fs_target"] = float(processed.fs_target) + g.attrs["export_selection"] = json.dumps(selection.to_dict()) + + if selection.output: + g.create_dataset("output", data=np.asarray(processed.output, float), compression="gzip") raw_sig = np.asarray(processed.raw_signal if processed.raw_signal is not None else np.full_like(processed.time, np.nan), float) raw_ref = np.asarray(processed.raw_reference if processed.raw_reference is not None else np.full_like(processed.time, np.nan), float) - g.create_dataset("raw_465", data=raw_sig, compression="gzip") - g.create_dataset("raw_405", data=raw_ref, compression="gzip") + if selection.raw: + g.create_dataset("raw_465", data=raw_sig, compression="gzip") + if selection.isobestic: + g.create_dataset("raw_405", data=raw_ref, compression="gzip") try: - if "raw" not in g: + if selection.raw and "raw" not in g: g["raw"] = g["raw_465"] - if "isobestic" not in g: + if selection.isobestic and "isobestic" not in g: g["isobestic"] = g["raw_405"] - if out_type and out_type != "output" and out_type not in g: + if selection.output and out_type and out_type != "output" and out_type not in g: g[out_type] = g["output"] except Exception: pass - if processed.dio is not None: - g.create_dataset("dio", data=np.asarray(processed.dio, float), compression="gzip") + if selection.dio and processed.dio is not None: + primary_name = str(processed.dio_name) if processed.dio_name else "dio" + g.create_dataset(primary_name, data=np.asarray(processed.dio, float), compression="gzip") g.attrs["dio_name"] = str(processed.dio_name) + if primary_name != "dio": + g["dio"] = g[primary_name] # link for compatibility - if processed.baseline_sig is not None: + # Add all selected triggers + if selection.dio and hasattr(processed, "triggers") and processed.triggers: + for name, val in processed.triggers.items(): + if name not in g: + g.create_dataset(name, data=np.asarray(val, float), compression="gzip") + + if selection.baseline_sig and processed.baseline_sig is not None: g.create_dataset("baseline_465", data=np.asarray(processed.baseline_sig, float), compression="gzip") - if processed.baseline_ref is not None: + if selection.baseline_ref and processed.baseline_ref is not None: g.create_dataset("baseline_405", data=np.asarray(processed.baseline_ref, float), compression="gzip") if metadata: @@ -434,6 +556,81 @@ def _lowpass_sos(x: np.ndarray, fs: float, cutoff: float, order: int) -> np.ndar return np.asarray(sosfiltfilt(sos, y), float) +def _window_samples_from_seconds( + fs: float, + window_s: float, + *, + minimum: int = 1, + require_odd: bool = False, +) -> int: + if not np.isfinite(fs) or fs <= 0: + return int(max(1, minimum)) + n = int(round(float(window_s) * float(fs))) + n = max(int(minimum), n) + if require_odd and (n % 2 == 0): + n += 1 + return int(n) + + +def _apply_optional_smoothing(x: np.ndarray, fs: float, params: ProcessingParams) -> np.ndarray: + """ + Optional smoothing stage applied on the processed timebase. + Supported methods: + - Savitzky-Golay + - Moving average + - Moving median + """ + y = np.asarray(x, float) + if y.size < 3: + return y + if not bool(getattr(params, "smoothing_enabled", False)): + return y + + method = str(getattr(params, "smoothing_method", "Savitzky-Golay") or "Savitzky-Golay").strip() + window_s = float(getattr(params, "smoothing_window_s", 0.0)) + if not np.isfinite(window_s) or window_s <= 0: + return y + + if np.any(~np.isfinite(y)): + y = interpolate_nans(y) + + if method.startswith("Savitzky"): + polyorder = int(max(1, getattr(params, "smoothing_polyorder", 2))) + win = _window_samples_from_seconds(fs, window_s, minimum=polyorder + 2, require_odd=True) + if win > y.size: + win = y.size if (y.size % 2 == 1) else max(1, y.size - 1) + if win <= polyorder: + polyorder = max(1, min(polyorder, win - 1)) + if win < 3 or win <= polyorder: + return y + try: + return np.asarray(savgol_filter(y, window_length=int(win), polyorder=int(polyorder), mode="interp"), float) + except Exception: + return y + + if method.startswith("Moving average"): + win = _window_samples_from_seconds(fs, window_s, minimum=1, require_odd=False) + if win <= 1: + return y + try: + return np.asarray(uniform_filter1d(y, size=int(win), mode="nearest"), float) + except Exception: + return y + + if method.startswith("Moving median"): + win = _window_samples_from_seconds(fs, window_s, minimum=3, require_odd=True) + if win > y.size: + win = y.size if (y.size % 2 == 1) else max(1, y.size - 1) + if win <= 1: + return y + try: + return np.asarray(median_filter(y, size=int(win), mode="nearest"), float) + except Exception: + return y + + return y + + def _compute_resample_ratio(fs: float, target_fs: float) -> Tuple[int, int, float]: """Compute a rational resampling ratio (up/down) limited to manageable denominators.""" from fractions import Fraction @@ -848,6 +1045,21 @@ def _read_time(folder: str) -> np.ndarray: if k.startswith("DIO"): digital_by[k] = np.asarray(dio[k][()], float) + trigger_by: Dict[str, np.ndarray] = dict(digital_by) + trigger_time_by: Dict[str, np.ndarray] = {} + if digital_time is not None: + for name in digital_by.keys(): + trigger_time_by[name] = np.asarray(digital_time, float) + + if "AnalogOut" in base: + aout = base["AnalogOut"] + aout_time = np.asarray(aout["Time"][()], float) if "Time" in aout else None + for k in aout.keys(): + if k.startswith("AOUT"): + trigger_by[k] = np.asarray(aout[k][()], float) + if aout_time is not None: + trigger_time_by[k] = np.asarray(aout_time, float) + return LoadedDoricFile( path=path, channels=chans, @@ -856,6 +1068,8 @@ def _read_time(folder: str) -> np.ndarray: reference_by_channel=ref_by, digital_time=digital_time, digital_by_name=digital_by, + trigger_time_by_name=trigger_time_by, + trigger_by_name=trigger_by, ) def make_preview_task( @@ -977,12 +1191,14 @@ def process_trial( # 6) Resample signals together to target fs (only if true decimation) # --------------------------------------------------------------------- t2, sig2, ref2, fs_used = _resample_pair_to_target_fs(t, sig_f, ref_f, fs, target_fs) + sig2 = _apply_optional_smoothing(sig2, fs_used, params) + ref2 = _apply_optional_smoothing(ref2, fs_used, params) # Resample the envelope for display (same timebase as processed) _, hi2, lo2, _ = _resample_pair_to_target_fs(t, hi_raw, lo_raw, fs, target_fs) # --------------------------------------------------------------------- - # 7) DIO overlay (if present): interpolate and binarize + # 7) A/D overlay (if present): interpolate and binarize # --------------------------------------------------------------------- dio2 = None dio_name = "" @@ -991,6 +1207,14 @@ def process_trial( dio_interp = np.interp(t2, t, np.asarray(trial.trigger, float)) dio2 = (dio_interp > 0.5).astype(float) + all_triggers2 = {} + if hasattr(trial, "triggers") and trial.triggers: + for name, val in trial.triggers.items(): + vt = trial.trigger_times.get(name) + if vt is not None: + interp_val = np.interp(t2, vt, np.asarray(val, float)) + all_triggers2[name] = (interp_val > 0.5).astype(float) + # --------------------------------------------------------------------- # 8) Baseline estimation AFTER filtering/resampling (on final timebase) # --------------------------------------------------------------------- @@ -998,7 +1222,7 @@ def process_trial( b_ref = self._baseline(t2, ref2, params) # --------------------------------------------------------------------- - # 9) Compute requested output mode (7 user-defined options) + # 9) Compute requested output mode # --------------------------------------------------------------------- mode = params.output_mode if params.output_mode in OUTPUT_MODES else OUTPUT_MODES[0] out: Optional[np.ndarray] = None @@ -1052,18 +1276,34 @@ def process_trial( dff_fit = safe_divide(sig2 - fitted_ref, fitted_ref) out = zscore_median_std(dff_fit) + elif mode == "Raw signal (465)": + # (8) raw signal (processed 465 trace) + # Directly expose the filtered/resampled 465 channel. + out = np.asarray(sig2, float) + else: # Safety fallback (should not happen if OUTPUT_MODES is authoritative) out = dff_sig - baseline_desc = f"Baseline: {params.baseline_method} (lambda={float(params.baseline_lambda):.2e})" context_parts = [] - if mode in ( - "dFF (motion corrected with fitted ref)", - "zscore (motion corrected with fitted ref)", - ): - context_parts.append(f"Fit: {params.reference_fit}") - context_parts.append(baseline_desc) + if mode == "Raw signal (465)": + context_parts.append("Raw 465 after artifact interpolation, filtering, and resampling") + else: + baseline_desc = f"Baseline: {params.baseline_method} (lambda={float(params.baseline_lambda):.2e})" + if mode in ( + "dFF (motion corrected with fitted ref)", + "zscore (motion corrected with fitted ref)", + ): + context_parts.append(f"Fit: {params.reference_fit}") + context_parts.append(baseline_desc) + if bool(getattr(params, "smoothing_enabled", False)): + sm_method = str(getattr(params, "smoothing_method", "Savitzky-Golay") or "Savitzky-Golay") + sm_win = float(getattr(params, "smoothing_window_s", 0.0)) + sm_desc = f"Smoothing: {sm_method} (window={sm_win:.3g}s" + if sm_method.startswith("Savitzky"): + sm_desc += f", poly={int(getattr(params, 'smoothing_polyorder', 2))}" + sm_desc += ")" + context_parts.append(sm_desc) output_context = " | ".join(context_parts) # --------------------------------------------------------------------- @@ -1079,6 +1319,7 @@ def process_trial( raw_thr_lo=lo2, dio=dio2, dio_name=dio_name, + triggers=all_triggers2, sig_f=sig2, ref_f=ref2, baseline_sig=b_sig, diff --git a/pyBer/gui_postprocessing.py b/pyBer/gui_postprocessing.py index 013690a..8d10876 100644 --- a/pyBer/gui_postprocessing.py +++ b/pyBer/gui_postprocessing.py @@ -4,18 +4,43 @@ import os import re import json +import logging from pathlib import Path from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Tuple import numpy as np from PySide6 import QtCore, QtWidgets, QtGui import pyqtgraph as pg +from pyqtgraph.dockarea import DockArea, Dock import h5py from analysis_core import ProcessedTrial from ethovision_process_gui import clean_sheet +_DOCK_STATE_VERSION = 3 +_POST_DOCK_STATE_KEY = "post_main_dock_state_v4" +_POST_DOCKAREA_STATE_KEY = "post_dockarea_state_v1" +_POST_DOCKAREA_VISIBLE_KEY = "post_dockarea_visible_v1" +_POST_DOCKAREA_ACTIVE_KEY = "post_dockarea_active_v1" +_POST_DOCK_PREFIX = "post." +_PRE_DOCK_PREFIX = "pre." +_BEHAVIOR_PARSE_BINARY = "binary_columns" +_BEHAVIOR_PARSE_TIMESTAMPS = "timestamp_columns" +_FIXED_POST_RIGHT_SECTIONS = frozenset({"setup", "spatial", "psth", "export"}) +_FIXED_POST_VISIBLE_SECTIONS = frozenset({"setup", "spatial", "psth", "export"}) +_FIXED_POST_RIGHT_TAB_ORDER = ("setup", "psth", "spatial", "export") +_POST_RIGHT_PANEL_MIN_WIDTH = 420 +_FIXED_POST_RIGHT_TAB_TITLES: Dict[str, str] = { + "setup": "Setup", + "psth": "PSTH", + "spatial": "Spatial", + "export": "Export", +} +_USE_PG_DOCKAREA_POST_LAYOUT = True +_LOG = logging.getLogger(__name__) + def _opt_plot(w: pg.PlotWidget) -> None: w.setMenuEnabled(True) @@ -33,6 +58,17 @@ def _compact_combo(combo: QtWidgets.QComboBox, min_chars: int = 6) -> None: combo.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) +_AIN_SUFFIX_RE = re.compile(r"_AIN0*([0-9]+)$", re.IGNORECASE) + + +def _strip_ain_suffix(name: str) -> str: + return _AIN_SUFFIX_RE.sub("", str(name)) + + +def _is_doric_channel_align(text: str) -> bool: + return "doric" in (text or "").strip().lower() + + class FileDropList(QtWidgets.QListWidget): filesDropped = QtCore.Signal(list) orderChanged = QtCore.Signal() @@ -82,6 +118,55 @@ def _extract_rising_edges(time: np.ndarray, dio: np.ndarray, threshold: float = on = np.where((~b[:-1]) & (b[1:]))[0] + 1 return t[on] + +def _dock_area_to_int(value: object, fallback: int = 2) -> int: + """ + Convert Qt DockWidgetArea enum/flag objects (or stored values) to int safely. + Some PySide6 builds do not allow int(Qt enum) directly. + """ + try: + enum_value = getattr(value, "value", None) + if enum_value is not None: + return int(enum_value) + except Exception: + pass + try: + if isinstance(value, str): + v = value.strip().lower() + if "left" in v: + return 1 + if "right" in v: + return 2 + if "top" in v: + return 4 + if "bottom" in v: + return 8 + except Exception: + pass + try: + return int(value) + except Exception: + return int(fallback) + + +def _to_bool(value: object, default: bool = False) -> bool: + """ + Convert mixed QSettings bool payloads (bool/int/str) safely. + """ + if isinstance(value, bool): + return value + if value is None: + return bool(default) + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + v = value.strip().lower() + if v in {"1", "true", "yes", "y", "on"}: + return True + if v in {"0", "false", "no", "n", "off", ""}: + return False + return bool(default) + def _extract_events_with_durations( time: np.ndarray, dio: np.ndarray, @@ -152,23 +237,45 @@ def _extract_onsets_offsets( fi += 1 return np.asarray(on, float), np.asarray(off, float), np.asarray(dur, float) -def _binary_columns_from_df(df) -> Tuple[str, Dict[str, np.ndarray]]: - cols = [c.strip() for c in df.columns] - time_col = None +def _detect_time_column(df, fallback_to_first: bool = False) -> Optional[str]: for c in df.columns: if str(c).strip().lower() in {"time", "trial time", "recording time"}: - time_col = c + return str(c) + if fallback_to_first and len(df.columns): + return str(df.columns[0]) + return None + + +def _numeric_column_array(df, col_name: str) -> np.ndarray: + import pandas as pd + + col_key = None + for c in df.columns: + if str(c) == str(col_name): + col_key = c break - if time_col is None and cols: - time_col = df.columns[0] + if col_key is None: + return np.array([], float) + vals = pd.to_numeric(df[col_key], errors="coerce") + return np.asarray(vals, float) + + +def _generated_time_array(n_rows: int, fps: float) -> np.ndarray: + n = int(max(0, n_rows)) + if n <= 0 or not np.isfinite(float(fps)) or float(fps) <= 0: + return np.array([], float) + return np.arange(n, dtype=float) / float(fps) - t = np.asarray(df[time_col], float) - behaviors: Dict[str, np.ndarray] = {} +def _binary_columns_from_df(df) -> Tuple[str, Dict[str, np.ndarray]]: + time_col = _detect_time_column(df, fallback_to_first=False) + + behaviors: Dict[str, np.ndarray] = {} for c in df.columns: - if c == time_col: + name = str(c) + if time_col and name == time_col: continue - arr = np.asarray(df[c], float) + arr = _numeric_column_array(df, name) if arr.size == 0: continue finite = arr[np.isfinite(arr)] @@ -176,28 +283,136 @@ def _binary_columns_from_df(df) -> Tuple[str, Dict[str, np.ndarray]]: continue uniq = np.unique(finite) if np.all(np.isin(uniq, [0.0, 1.0])): - behaviors[str(c)] = arr.astype(float) - + behaviors[name] = arr.astype(float) return time_col, behaviors -def _load_behavior_csv(path: str) -> Dict[str, Any]: +def _trajectory_columns_from_df(df, time_col: Optional[str]) -> Dict[str, np.ndarray]: + trajectory: Dict[str, np.ndarray] = {} + for c in df.columns: + name = str(c).strip() + if not name: + continue + if time_col and name == time_col: + continue + arr = _numeric_column_array(df, name) + if arr.size == 0: + continue + finite = arr[np.isfinite(arr)] + if finite.size < 8: + continue + uniq = np.unique(finite) + # Skip classic binary behavior columns from trajectory candidates. + if uniq.size <= 2 and np.all(np.isin(uniq, [0.0, 1.0])): + continue + trajectory[name] = arr + return trajectory + + +def _timestamp_columns_from_df(df) -> Dict[str, np.ndarray]: + import pandas as pd + + behaviors: Dict[str, np.ndarray] = {} + for c in df.columns: + name = str(c).strip() + if not name: + continue + if name.lower() in {"time", "trial time", "recording time", "timestamp", "timestamps"}: + continue + vals = pd.to_numeric(df[c], errors="coerce") + arr = np.asarray(vals, float) + arr = arr[np.isfinite(arr)] + if arr.size == 0: + continue + behaviors[name] = np.sort(np.unique(arr)) + return behaviors + + +def _load_behavior_csv(path: str, parse_mode: str = _BEHAVIOR_PARSE_BINARY, fps: float = 0.0) -> Dict[str, Any]: import pandas as pd + df = pd.read_csv(path) + row_count = int(len(df.index)) + time_col = _detect_time_column(df) + trajectory = _trajectory_columns_from_df(df, time_col=time_col) + trajectory_time = _numeric_column_array(df, time_col) if time_col else np.array([], float) + if str(parse_mode) == _BEHAVIOR_PARSE_TIMESTAMPS: + return { + "kind": _BEHAVIOR_PARSE_TIMESTAMPS, + "time": np.array([], float), + "behaviors": _timestamp_columns_from_df(df), + "trajectory": trajectory, + "trajectory_time": trajectory_time if trajectory_time.size else _generated_time_array(row_count, fps), + "trajectory_time_col": time_col or "", + "row_count": row_count, + "needs_generated_time": bool(not time_col), + } time_col, behaviors = _binary_columns_from_df(df) - return {"kind": "binary_columns", "time": np.asarray(df[time_col], float), "behaviors": behaviors} - + time = _numeric_column_array(df, time_col) if time_col else _generated_time_array(row_count, fps) + return { + "kind": _BEHAVIOR_PARSE_BINARY, + "time": time, + "behaviors": behaviors, + "trajectory": trajectory, + "trajectory_time": trajectory_time if trajectory_time.size else (time if time.size else _generated_time_array(row_count, fps)), + "trajectory_time_col": time_col or "", + "row_count": row_count, + "needs_generated_time": bool(not time_col), + } + + +def _load_behavior_ethovision( + path: str, + sheet_name: Optional[str] = None, + parse_mode: str = _BEHAVIOR_PARSE_BINARY, + fps: float = 0.0, +) -> Dict[str, Any]: + import pandas as pd -def _load_behavior_ethovision(path: str, sheet_name: Optional[str] = None) -> Dict[str, Any]: if sheet_name is None: - import pandas as pd xls = pd.ExcelFile(path, engine="openpyxl") sheet_name = xls.sheet_names[0] if xls.sheet_names else None if not sheet_name: - return {"kind": "binary_columns", "time": np.array([], float), "behaviors": {}} + return { + "kind": _BEHAVIOR_PARSE_BINARY, + "time": np.array([], float), + "behaviors": {}, + "trajectory": {}, + "trajectory_time": np.array([], float), + "trajectory_time_col": "", + "row_count": 0, + "needs_generated_time": False, + } + if str(parse_mode) == _BEHAVIOR_PARSE_TIMESTAMPS: + df = pd.read_excel(path, sheet_name=sheet_name, engine="openpyxl") + time_col = _detect_time_column(df) + row_count = int(len(df.index)) + return { + "kind": _BEHAVIOR_PARSE_TIMESTAMPS, + "time": np.array([], float), + "behaviors": _timestamp_columns_from_df(df), + "trajectory": _trajectory_columns_from_df(df, time_col=time_col), + "trajectory_time": _numeric_column_array(df, time_col) if time_col else _generated_time_array(row_count, fps), + "trajectory_time_col": time_col or "", + "sheet": sheet_name, + "row_count": row_count, + "needs_generated_time": bool(not time_col), + } df = clean_sheet(Path(path), sheet_name, interpolate=True) + row_count = int(len(df.index)) time_col, behaviors = _binary_columns_from_df(df) - return {"kind": "binary_columns", "time": np.asarray(df[time_col], float), "behaviors": behaviors, "sheet": sheet_name} + time = _numeric_column_array(df, time_col) if time_col else _generated_time_array(row_count, fps) + return { + "kind": _BEHAVIOR_PARSE_BINARY, + "time": time, + "behaviors": behaviors, + "trajectory": _trajectory_columns_from_df(df, time_col=time_col), + "trajectory_time": _numeric_column_array(df, time_col) if time_col else _generated_time_array(row_count, fps), + "trajectory_time_col": time_col or "", + "sheet": sheet_name, + "row_count": row_count, + "needs_generated_time": bool(not time_col), + } def _compute_psth_matrix( @@ -260,6 +475,7 @@ class PostProcessingPanel(QtWidgets.QWidget): requestCurrentProcessed = QtCore.Signal() requestDioList = QtCore.Signal() requestDioData = QtCore.Signal(str, str) # (path, dio) + statusUpdate = QtCore.Signal(str, int) def __init__(self, parent=None) -> None: super().__init__(parent) @@ -272,8 +488,16 @@ def __init__(self, parent=None) -> None: self._last_durations: Optional[np.ndarray] = None self._last_metrics: Optional[Dict[str, float]] = None self._last_global_metrics: Optional[Dict[str, float]] = None + self._last_spatial_occupancy_map: Optional[np.ndarray] = None + self._last_spatial_activity_map: Optional[np.ndarray] = None + self._last_spatial_velocity_map: Optional[np.ndarray] = None + self._last_spatial_extent: Optional[Tuple[float, float, float, float]] = None + self._last_event_rows: List[Dict[str, object]] = [] + self.last_signal_events: Optional[Dict[str, object]] = None + self.last_behavior_analysis: Optional[Dict[str, object]] = None self._event_labels: List[pg.TextItem] = [] self._event_regions: List[pg.LinearRegionItem] = [] + self._signal_peak_lines: List[pg.InfiniteLine] = [] self._pre_region: Optional[pg.LinearRegionItem] = None self._post_region: Optional[pg.LinearRegionItem] = None self._settings = QtCore.QSettings("FiberPhotometryApp", "DoricProcessor") @@ -281,27 +505,75 @@ def __init__(self, parent=None) -> None: "trace": (90, 190, 255), "behavior": (220, 180, 80), "avg": (90, 190, 255), + "sem_edge": (152, 201, 143), + "sem_fill": (188, 230, 178, 96), + "plot_bg": (36, 42, 52), + "grid_enabled": True, + "grid_alpha": 0.25, "heatmap_cmap": "viridis", "heatmap_min": None, "heatmap_max": None, } - self._build_ui() - self._restore_settings() + self._section_popups: Dict[str, QtWidgets.QDockWidget] = {} + self._section_scroll_hosts: Dict[str, QtWidgets.QScrollArea] = {} + self._section_buttons: Dict[str, QtWidgets.QPushButton] = {} + self._use_pg_dockarea_layout: bool = bool(_USE_PG_DOCKAREA_POST_LAYOUT) + self._dockarea: Optional[DockArea] = None + self._dockarea_docks: Dict[str, Dock] = {} + self._dockarea_splitter: Optional[QtWidgets.QSplitter] = None + self._fixed_right_tab_widget: Optional[QtWidgets.QTabWidget] = None + self._section_popup_initialized: set[str] = set() + self._is_restoring_settings: bool = True + self._settings_save_timer = QtCore.QTimer(self) + self._settings_save_timer.setSingleShot(True) + self._settings_save_timer.setInterval(250) + self._settings_save_timer.timeout.connect(self._save_settings) + self._is_restoring_panel_layout: bool = False + self._panel_layout_persistence_ready: bool = False + self._last_opened_section: Optional[str] = None + self._suspend_panel_layout_persistence: bool = False + self._post_docks_hidden_for_tab_switch: bool = False + self._post_section_visibility_before_hide: Dict[str, bool] = {} + self._post_section_state_before_hide: Dict[str, Dict[str, object]] = {} + self._dock_host: Optional[QtWidgets.QMainWindow] = None + self._dock_layout_restored: bool = False + self._app_closing: bool = False + self._post_snapshot_applied: bool = False + self._force_fixed_default_layout: bool = False + self._app_theme_mode: str = "dark" + self._applying_fixed_default_layout: bool = False + self._pending_fixed_layout_retry: bool = False + self._pending_project_recompute_from_current: bool = False + self._project_dirty: bool = False + self._autosave_restoring: bool = False + self._project_recovered_from_autosave: bool = False + try: + self._build_ui() + self._restore_settings() + finally: + self._is_restoring_settings = False + self._panel_layout_persistence_ready = True + app = QtWidgets.QApplication.instance() + if app is not None: + app.aboutToQuit.connect(self._on_about_to_quit) + QtCore.QTimer.singleShot(0, self._restore_project_autosave_if_needed) def _build_ui(self) -> None: - root = QtWidgets.QHBoxLayout(self) + root = QtWidgets.QVBoxLayout(self) root.setContentsMargins(10, 10, 10, 10) - root.setSpacing(10) - - # Left controls (scroll) - left = QtWidgets.QWidget() - lv = QtWidgets.QVBoxLayout(left) - lv.setSpacing(10) + root.setSpacing(8) grp_src = QtWidgets.QGroupBox("Signal Source") + grp_src.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Maximum) vsrc = QtWidgets.QVBoxLayout(grp_src) self.tab_sources = QtWidgets.QTabWidget() + self.tab_sources.setObjectName("postSourceTabs") + self.tab_sources.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Maximum) + self.tab_sources.setStyleSheet( + "QTabWidget::pane { border: 0px; background: transparent; padding: 0px; }" + "QTabBar::tab { margin-right: 0px; }" + ) tab_single = QtWidgets.QWidget() tab_group = QtWidgets.QWidget() self.tab_sources.addTab(tab_single, "Single") @@ -321,7 +593,6 @@ def _build_ui(self) -> None: single_layout.addWidget(self.lbl_current) single_layout.addWidget(self.btn_use_current) single_layout.addWidget(self.btn_load_processed_single) - single_layout.addStretch(1) group_layout = QtWidgets.QVBoxLayout(tab_group) self.btn_load_processed = QtWidgets.QPushButton("Load processed files (CSV/H5)") @@ -331,9 +602,8 @@ def _build_ui(self) -> None: self.lbl_group.setProperty("class", "hint") group_layout.addWidget(self.btn_load_processed) group_layout.addWidget(self.lbl_group) - group_layout.addStretch(1) - self.btn_refresh_dio = QtWidgets.QPushButton("Refresh DIO list") + self.btn_refresh_dio = QtWidgets.QPushButton("Refresh A/D channel list") self.btn_refresh_dio.setProperty("class", "compactSmall") self.btn_refresh_dio.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) @@ -346,22 +616,57 @@ def _build_ui(self) -> None: fal.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) self.combo_align = QtWidgets.QComboBox() - self.combo_align.addItems(["DIO (from Doric)", "Behavior (CSV/XLSX)"]) + self.combo_align.addItems(["Analog/Digital channel (from Doric)", "Behavior (CSV/XLSX)"]) self.combo_align.setCurrentIndex(1) _compact_combo(self.combo_align, min_chars=6) self.combo_dio = QtWidgets.QComboBox() _compact_combo(self.combo_dio, min_chars=6) self.combo_dio_polarity = QtWidgets.QComboBox() - self.combo_dio_polarity.addItems(["Event high (0→1)", "Event low (1→0)"]) + self.combo_dio_polarity.addItems(["Event high (0->1)", "Event low (1->0)"]) _compact_combo(self.combo_dio_polarity, min_chars=6) self.combo_dio_align = QtWidgets.QComboBox() self.combo_dio_align.addItems(["Align to onset", "Align to offset"]) _compact_combo(self.combo_dio_align, min_chars=6) + self.lbl_dio_channel = QtWidgets.QLabel("A/D channel") + self.lbl_dio_polarity = QtWidgets.QLabel("A/D polarity") + self.lbl_dio_align = QtWidgets.QLabel("A/D align") - self.btn_load_beh = QtWidgets.QPushButton("Load behavior CSV/XLSX…") + self.btn_load_beh = QtWidgets.QPushButton("Load behavior CSV/XLSX...") self.btn_load_beh.setProperty("class", "compactSmall") self.btn_load_beh.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.lbl_behavior_file_type = QtWidgets.QLabel("Behavior file type") + self.combo_behavior_file_type = QtWidgets.QComboBox() + self.combo_behavior_file_type.addItems( + [ + "Binary states (time + 0/1 columns)", + "Timestamps per behavior (columns = behaviors)", + ] + ) + self.combo_behavior_file_type.setToolTip( + "Choose how behavior files are parsed.\n" + "Binary states expects a time column and 0/1 behavior columns.\n" + "Timestamps mode expects one column per behavior containing event times." + ) + _compact_combo(self.combo_behavior_file_type, min_chars=10) + self.grp_behavior_time = QtWidgets.QGroupBox("Time") + time_layout = QtWidgets.QHBoxLayout(self.grp_behavior_time) + time_layout.setContentsMargins(6, 6, 6, 6) + time_layout.setSpacing(6) + self.lbl_behavior_time_hint = QtWidgets.QLabel("No time column detected. Generate time from FPS.") + self.lbl_behavior_time_hint.setProperty("class", "hint") + self.spin_behavior_fps = QtWidgets.QDoubleSpinBox() + self.spin_behavior_fps.setRange(0.01, 10000.0) + self.spin_behavior_fps.setDecimals(3) + self.spin_behavior_fps.setValue(30.0) + self.spin_behavior_fps.setSuffix(" fps") + self.spin_behavior_fps.setMinimumWidth(90) + self.btn_apply_behavior_time = QtWidgets.QPushButton("Apply FPS") + self.btn_apply_behavior_time.setProperty("class", "compactSmall") + time_layout.addWidget(self.lbl_behavior_time_hint, stretch=1) + time_layout.addWidget(self.spin_behavior_fps, stretch=0) + time_layout.addWidget(self.btn_apply_behavior_time, stretch=0) + self.grp_behavior_time.setVisible(False) self.lbl_beh = QtWidgets.QLabel("(none)") self.lbl_beh.setProperty("class", "hint") @@ -377,8 +682,8 @@ def _build_ui(self) -> None: # Control buttons for ordering order_layout = QtWidgets.QHBoxLayout() - self.btn_move_up = QtWidgets.QPushButton("↑ Move Up") - self.btn_move_down = QtWidgets.QPushButton("↓ Move Down") + self.btn_move_up = QtWidgets.QPushButton("Move Up") + self.btn_move_down = QtWidgets.QPushButton("Move Down") self.btn_auto_match = QtWidgets.QPushButton("Auto Match") self.btn_remove_pre = QtWidgets.QPushButton("Remove selected") self.btn_remove_beh = QtWidgets.QPushButton("Remove selected") @@ -410,20 +715,22 @@ def _build_ui(self) -> None: lists_layout.addLayout(beh_col) fal.addRow("Align source", self.combo_align) - fal.addRow("DIO channel", self.combo_dio) - fal.addRow("DIO polarity", self.combo_dio_polarity) - fal.addRow("DIO align", self.combo_dio_align) + fal.addRow(self.lbl_dio_channel, self.combo_dio) + fal.addRow(self.lbl_dio_polarity, self.combo_dio_polarity) + fal.addRow(self.lbl_dio_align, self.combo_dio_align) + fal.addRow(self.lbl_behavior_file_type, self.combo_behavior_file_type) + fal.addRow(self.grp_behavior_time) fal.addRow(self.btn_load_beh) fal.addRow("Loaded files", self.lbl_beh) fal.addRow(files_layout) fal.addRow(lists_layout) fal.addRow(order_layout) - # Legacy behavior controls (hidden by default but kept for compatibility) + # Behavior controls self.combo_behavior_name = QtWidgets.QComboBox() _compact_combo(self.combo_behavior_name, min_chars=6) self.combo_behavior_align = QtWidgets.QComboBox() - self.combo_behavior_align.addItems(["Align to onset", "Align to offset", "Transition A→B"]) + self.combo_behavior_align.addItems(["Align to onset", "Align to offset", "Transition A->B"]) _compact_combo(self.combo_behavior_align, min_chars=6) self.combo_behavior_from = QtWidgets.QComboBox() self.combo_behavior_to = QtWidgets.QComboBox() @@ -434,21 +741,15 @@ def _build_ui(self) -> None: self.spin_transition_gap.setValue(1.0) self.spin_transition_gap.setDecimals(2) - # Hide legacy controls initially - legacy_group = QtWidgets.QGroupBox("Legacy Behavior Selection (deprecated)") - legacy_group.setCheckable(True) - legacy_group.setChecked(False) - legacy_layout = QtWidgets.QFormLayout(legacy_group) - legacy_layout.addRow("Behavior name", self.combo_behavior_name) - legacy_layout.addRow("Behavior align", self.combo_behavior_align) self.lbl_trans_from = QtWidgets.QLabel("Transition from") self.lbl_trans_to = QtWidgets.QLabel("Transition to") self.lbl_trans_gap = QtWidgets.QLabel("Transition gap (s)") - legacy_layout.addRow(self.lbl_trans_from, self.combo_behavior_from) - legacy_layout.addRow(self.lbl_trans_to, self.combo_behavior_to) - legacy_layout.addRow(self.lbl_trans_gap, self.spin_transition_gap) + fal.addRow("Behavior name", self.combo_behavior_name) + fal.addRow("Behavior align", self.combo_behavior_align) + fal.addRow(self.lbl_trans_from, self.combo_behavior_from) + fal.addRow(self.lbl_trans_to, self.combo_behavior_to) + fal.addRow(self.lbl_trans_gap, self.spin_transition_gap) - fal.addRow(legacy_group) grp_opt = QtWidgets.QGroupBox("PSTH Options") fopt = QtWidgets.QFormLayout(grp_opt) @@ -470,6 +771,7 @@ def _build_ui(self) -> None: self.btn_hide_filters.setCheckable(True) self.spin_event_start = QtWidgets.QSpinBox(); self.spin_event_start.setRange(1, 1000000); self.spin_event_start.setValue(1) self.spin_event_end = QtWidgets.QSpinBox(); self.spin_event_end.setRange(0, 1000000); self.spin_event_end.setValue(0) + self.spin_group_window = QtWidgets.QDoubleSpinBox(); self.spin_group_window.setRange(0.0, 1e6); self.spin_group_window.setValue(0.0); self.spin_group_window.setDecimals(3) self.spin_dur_min = QtWidgets.QDoubleSpinBox(); self.spin_dur_min.setRange(0, 1e6); self.spin_dur_min.setValue(0.0); self.spin_dur_min.setDecimals(2) self.spin_dur_max = QtWidgets.QDoubleSpinBox(); self.spin_dur_max.setRange(0, 1e6); self.spin_dur_max.setValue(0.0); self.spin_dur_max.setDecimals(2) self.cb_metrics = QtWidgets.QCheckBox("Enable PSTH metrics") @@ -488,7 +790,7 @@ def _build_ui(self) -> None: for w in ( self.spin_pre, self.spin_post, self.spin_b0, self.spin_b1, self.spin_resample, self.spin_smooth, - self.spin_event_start, self.spin_event_end, self.spin_dur_min, self.spin_dur_max, + self.spin_event_start, self.spin_event_end, self.spin_group_window, self.spin_dur_min, self.spin_dur_max, self.spin_metric_pre0, self.spin_metric_pre1, self.spin_metric_post0, self.spin_metric_post1, ): w.setMinimumWidth(60) @@ -558,6 +860,8 @@ def _build_ui(self) -> None: fopt.addRow("Baseline (s)", base_widget) fopt.addRow("Resample (Hz)", self.spin_resample) filt_row = QtWidgets.QHBoxLayout() + filt_row.setContentsMargins(0, 0, 0, 0) + filt_row.setSpacing(6) filt_row.addWidget(self.cb_filter_events) filt_row.addStretch(1) filt_row.addWidget(self.btn_hide_filters) @@ -565,14 +869,18 @@ def _build_ui(self) -> None: fopt.addRow(filt_widget) self.lbl_event_start = QtWidgets.QLabel("Event index start (1-based)") self.lbl_event_end = QtWidgets.QLabel("Event index end (0=all)") + self.lbl_group_window = QtWidgets.QLabel("Group events within (s) (0=off)") self.lbl_dur_min = QtWidgets.QLabel("Event duration min (s)") self.lbl_dur_max = QtWidgets.QLabel("Event duration max (s)") fopt.addRow(self.lbl_event_start, self.spin_event_start) fopt.addRow(self.lbl_event_end, self.spin_event_end) + fopt.addRow(self.lbl_group_window, self.spin_group_window) fopt.addRow(self.lbl_dur_min, self.spin_dur_min) fopt.addRow(self.lbl_dur_max, self.spin_dur_max) - fopt.addRow("Gaussian smooth σ (s)", self.spin_smooth) + fopt.addRow("Gaussian smooth sigma (s)", self.spin_smooth) met_row = QtWidgets.QHBoxLayout() + met_row.setContentsMargins(0, 0, 0, 0) + met_row.setSpacing(6) met_row.addWidget(self.cb_metrics) met_row.addStretch(1) met_row.addWidget(self.btn_hide_metrics) @@ -620,6 +928,8 @@ def _build_ui(self) -> None: self.cb_global_freq = QtWidgets.QCheckBox("Transient frequency") self.cb_global_freq.setChecked(True) global_opts = QtWidgets.QHBoxLayout() + global_opts.setContentsMargins(0, 0, 0, 0) + global_opts.setSpacing(6) global_opts.addWidget(self.cb_global_amp) global_opts.addWidget(self.cb_global_freq) global_opts.addStretch(1) @@ -635,7 +945,7 @@ def _build_ui(self) -> None: w.setMinimumWidth(60) w.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) - self.btn_compute = QtWidgets.QPushButton("Post-process (compute PSTH)") + self.btn_compute = QtWidgets.QPushButton("Postprocessing (compute PSTH)") self.btn_compute.setProperty("class", "compactPrimarySmall") self.btn_compute.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) self.btn_update = QtWidgets.QPushButton("Update Preview") @@ -647,67 +957,611 @@ def _build_ui(self) -> None: self.btn_export_img = QtWidgets.QPushButton("Export images") self.btn_export_img.setProperty("class", "compactSmall") self.btn_export_img.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) - self.btn_style = QtWidgets.QPushButton("Styling") + self.btn_style = QtWidgets.QPushButton("Plot style") self.btn_style.setProperty("class", "compactSmall") - self.btn_style.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_style.setSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed) self.btn_save_cfg = QtWidgets.QPushButton("Save config") self.btn_save_cfg.setProperty("class", "compactSmall") self.btn_save_cfg.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) self.btn_load_cfg = QtWidgets.QPushButton("Load config") self.btn_load_cfg.setProperty("class", "compactSmall") self.btn_load_cfg.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_new_project = QtWidgets.QPushButton("New project") + self.btn_new_project.setProperty("class", "compactSmall") + self.btn_new_project.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_save_project = QtWidgets.QPushButton("Save project (.h5)") + self.btn_save_project.setProperty("class", "compactSmall") + self.btn_save_project.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_load_project = QtWidgets.QPushButton("Load project (.h5)") + self.btn_load_project.setProperty("class", "compactSmall") + self.btn_load_project.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + + grp_signal = QtWidgets.QGroupBox("Signal Event Analyzer") + f_signal = QtWidgets.QFormLayout(grp_signal) + f_signal.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) + f_signal.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) + self.combo_signal_source = QtWidgets.QComboBox() + self.combo_signal_source.addItems(["Use processed output trace (loaded file)", "Use PSTH input trace"]) + _compact_combo(self.combo_signal_source, min_chars=10) + self.combo_signal_scope = QtWidgets.QComboBox() + self.combo_signal_scope.addItems(["Per file", "Pooled"]) + _compact_combo(self.combo_signal_scope, min_chars=8) + self.combo_signal_file = QtWidgets.QComboBox() + _compact_combo(self.combo_signal_file, min_chars=8) + self.combo_signal_method = QtWidgets.QComboBox() + self.combo_signal_method.addItems(["SciPy find_peaks"]) + self.spin_peak_prominence = QtWidgets.QDoubleSpinBox() + self.spin_peak_prominence.setRange(0.0, 1e6) + self.spin_peak_prominence.setValue(0.5) + self.spin_peak_prominence.setDecimals(4) + self.spin_peak_height = QtWidgets.QDoubleSpinBox() + self.spin_peak_height.setRange(0.0, 1e6) + self.spin_peak_height.setValue(0.0) + self.spin_peak_height.setDecimals(4) + self.spin_peak_distance = QtWidgets.QDoubleSpinBox() + self.spin_peak_distance.setRange(0.0, 3600.0) + self.spin_peak_distance.setValue(0.5) + self.spin_peak_distance.setDecimals(3) + self.spin_peak_smooth = QtWidgets.QDoubleSpinBox() + self.spin_peak_smooth.setRange(0.0, 30.0) + self.spin_peak_smooth.setValue(0.0) + self.spin_peak_smooth.setDecimals(3) + self.combo_peak_baseline = QtWidgets.QComboBox() + self.combo_peak_baseline.addItems(["Use trace as-is", "Detrend with rolling median", "Detrend with rolling mean"]) + self.spin_peak_baseline_window = QtWidgets.QDoubleSpinBox() + self.spin_peak_baseline_window.setRange(0.1, 3600.0) + self.spin_peak_baseline_window.setValue(10.0) + self.spin_peak_baseline_window.setDecimals(2) + self.spin_peak_rate_bin = QtWidgets.QDoubleSpinBox() + self.spin_peak_rate_bin.setRange(0.5, 3600.0) + self.spin_peak_rate_bin.setValue(60.0) + self.spin_peak_rate_bin.setDecimals(2) + self.spin_peak_auc_window = QtWidgets.QDoubleSpinBox() + self.spin_peak_auc_window.setRange(0.0, 30.0) + self.spin_peak_auc_window.setValue(0.5) + self.spin_peak_auc_window.setDecimals(3) + self.cb_peak_overlay = QtWidgets.QCheckBox("Show detected peaks on trace") + self.cb_peak_overlay.setChecked(True) + self.btn_detect_peaks = QtWidgets.QPushButton("Detect peaks") + self.btn_detect_peaks.setProperty("class", "compactPrimarySmall") + self.btn_export_peaks = QtWidgets.QPushButton("Export peaks CSV") + self.btn_export_peaks.setProperty("class", "compactSmall") + self.lbl_signal_msg = QtWidgets.QLabel("") + self.lbl_signal_msg.setProperty("class", "hint") + f_signal.addRow("Signal source", self.combo_signal_source) + f_signal.addRow("Group mode", self.combo_signal_scope) + f_signal.addRow("File", self.combo_signal_file) + f_signal.addRow("Method", self.combo_signal_method) + f_signal.addRow("Min prominence", self.spin_peak_prominence) + f_signal.addRow("Min height (0=off)", self.spin_peak_height) + f_signal.addRow("Min distance (s)", self.spin_peak_distance) + f_signal.addRow("Smooth sigma (s)", self.spin_peak_smooth) + f_signal.addRow("Baseline handling", self.combo_peak_baseline) + f_signal.addRow("Baseline window (s)", self.spin_peak_baseline_window) + f_signal.addRow("Rate bin (s)", self.spin_peak_rate_bin) + f_signal.addRow("AUC window (+/- s)", self.spin_peak_auc_window) + f_signal.addRow(self.cb_peak_overlay) + signal_btn_row = QtWidgets.QHBoxLayout() + signal_btn_row.addWidget(self.btn_detect_peaks) + signal_btn_row.addWidget(self.btn_export_peaks) + signal_btn_row.addStretch(1) + signal_btn_wrap = QtWidgets.QWidget() + signal_btn_wrap.setLayout(signal_btn_row) + f_signal.addRow(signal_btn_wrap) + + self.tbl_signal_metrics = QtWidgets.QTableWidget(0, 2) + self.tbl_signal_metrics.setHorizontalHeaderLabels(["Metric", "Value"]) + self.tbl_signal_metrics.verticalHeader().setVisible(False) + self.tbl_signal_metrics.horizontalHeader().setStretchLastSection(True) + self.tbl_signal_metrics.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + self.tbl_signal_metrics.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection) + + grp_behavior_analysis = QtWidgets.QGroupBox("Behavior Analysis") + f_behavior = QtWidgets.QFormLayout(grp_behavior_analysis) + f_behavior.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) + f_behavior.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) + self.combo_behavior_analysis = QtWidgets.QComboBox() + _compact_combo(self.combo_behavior_analysis, min_chars=8) + self.spin_behavior_bin = QtWidgets.QDoubleSpinBox() + self.spin_behavior_bin.setRange(0.5, 3600.0) + self.spin_behavior_bin.setValue(30.0) + self.spin_behavior_bin.setDecimals(2) + self.cb_behavior_aligned = QtWidgets.QCheckBox("Use aligned timeline when possible") + self.cb_behavior_aligned.setChecked(False) + self.btn_compute_behavior = QtWidgets.QPushButton("Compute behavior metrics") + self.btn_compute_behavior.setProperty("class", "compactPrimarySmall") + self.btn_export_behavior_metrics = QtWidgets.QPushButton("Export behavior metrics") + self.btn_export_behavior_metrics.setProperty("class", "compactSmall") + self.btn_export_behavior_events = QtWidgets.QPushButton("Export event list") + self.btn_export_behavior_events.setProperty("class", "compactSmall") + self.lbl_behavior_msg = QtWidgets.QLabel("") + self.lbl_behavior_msg.setProperty("class", "hint") + f_behavior.addRow("Behavior", self.combo_behavior_analysis) + f_behavior.addRow("Bin size (s)", self.spin_behavior_bin) + f_behavior.addRow(self.cb_behavior_aligned) + behavior_btn_row = QtWidgets.QHBoxLayout() + behavior_btn_row.addWidget(self.btn_compute_behavior) + behavior_btn_row.addWidget(self.btn_export_behavior_metrics) + behavior_btn_row.addWidget(self.btn_export_behavior_events) + behavior_btn_row.addStretch(1) + behavior_btn_wrap = QtWidgets.QWidget() + behavior_btn_wrap.setLayout(behavior_btn_row) + f_behavior.addRow(behavior_btn_wrap) + + self.tbl_behavior_metrics = QtWidgets.QTableWidget(0, 8) + self.tbl_behavior_metrics.setHorizontalHeaderLabels( + [ + "file_id", + "event_count", + "total_time", + "mean_duration", + "median_duration", + "std_duration", + "rate_per_min", + "fraction_time", + ] + ) + self.tbl_behavior_metrics.verticalHeader().setVisible(False) + self.tbl_behavior_metrics.horizontalHeader().setStretchLastSection(True) + self.tbl_behavior_metrics.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + self.tbl_behavior_metrics.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.lbl_behavior_summary = QtWidgets.QLabel("Group metrics: -") + self.lbl_behavior_summary.setProperty("class", "hint") + + grp_spatial = QtWidgets.QGroupBox("Spatial") + f_spatial = QtWidgets.QFormLayout(grp_spatial) + f_spatial.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.DontWrapRows) + f_spatial.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) + + self.combo_spatial_x = QtWidgets.QComboBox() + self.combo_spatial_y = QtWidgets.QComboBox() + _compact_combo(self.combo_spatial_x, min_chars=10) + _compact_combo(self.combo_spatial_y, min_chars=10) + self.spin_spatial_bins_x = QtWidgets.QSpinBox() + self.spin_spatial_bins_x.setRange(8, 512) + self.spin_spatial_bins_x.setValue(64) + self.spin_spatial_bins_y = QtWidgets.QSpinBox() + self.spin_spatial_bins_y.setRange(8, 512) + self.spin_spatial_bins_y.setValue(64) + self.combo_spatial_weight = QtWidgets.QComboBox() + self.combo_spatial_weight.addItems( + [ + "Occupancy (samples)", + "Occupancy time (s)", + "Probability (% of time)", + ] + ) + _compact_combo(self.combo_spatial_weight, min_chars=10) + self.cb_spatial_clip = QtWidgets.QCheckBox("Enabled") + self.cb_spatial_clip.setChecked(True) + self.spin_spatial_clip_low = QtWidgets.QDoubleSpinBox() + self.spin_spatial_clip_low.setRange(0.0, 49.0) + self.spin_spatial_clip_low.setValue(1.0) + self.spin_spatial_clip_low.setDecimals(2) + self.spin_spatial_clip_high = QtWidgets.QDoubleSpinBox() + self.spin_spatial_clip_high.setRange(51.0, 100.0) + self.spin_spatial_clip_high.setValue(99.0) + self.spin_spatial_clip_high.setDecimals(2) + self.spin_spatial_smooth = QtWidgets.QDoubleSpinBox() + self.spin_spatial_smooth.setRange(0.0, 20.0) + self.spin_spatial_smooth.setValue(0.0) + self.spin_spatial_smooth.setDecimals(2) + self.cb_spatial_log = QtWidgets.QCheckBox("Log scale (log1p)") + self.cb_spatial_log.setChecked(False) + self.cb_spatial_invert_y = QtWidgets.QCheckBox("Invert Y axis") + self.cb_spatial_invert_y.setChecked(False) + self.combo_spatial_activity_mode = QtWidgets.QComboBox() + self.combo_spatial_activity_mode.addItems( + [ + "Mean z-score/bin (occupancy normalized)", + "Mean z-score/bin (velocity normalized)", + "Sum z-score/bin (no normalization)", + ] + ) + self.combo_spatial_activity_mode.setCurrentIndex(0) + _compact_combo(self.combo_spatial_activity_mode, min_chars=12) + self.cb_spatial_time_filter = QtWidgets.QCheckBox("Enabled") + self.cb_spatial_time_filter.setChecked(False) + self.spin_spatial_time_min = QtWidgets.QDoubleSpinBox() + self.spin_spatial_time_min.setRange(-1e9, 1e9) + self.spin_spatial_time_min.setDecimals(3) + self.spin_spatial_time_min.setValue(0.0) + self.spin_spatial_time_max = QtWidgets.QDoubleSpinBox() + self.spin_spatial_time_max.setRange(-1e9, 1e9) + self.spin_spatial_time_max.setDecimals(3) + self.spin_spatial_time_max.setValue(0.0) + self.btn_spatial_help = QtWidgets.QToolButton() + self.btn_spatial_help.setText("?") + self.btn_spatial_help.setToolTip( + "Spatial heatmap help:\n" + "- Top plot: occupancy map.\n" + "- Middle plot: activity map (mode selected below).\n" + "- Bottom plot: velocity map (mean speed/bin).\n" + "- Mean z-score/bin (occupancy normalized) = sum(z*weight) / sum(weight).\n" + "- Mean z-score/bin (velocity normalized) = sum(z*weight) / sum(speed*weight).\n" + "- Enable Time filter to restrict trajectory/activity samples to [min,max] seconds.\n" + "- Use right-side color cursors on each plot to set min/max display range." + ) + self.btn_compute_spatial = QtWidgets.QPushButton("Compute spatial heatmap") + self.btn_compute_spatial.setProperty("class", "compactPrimarySmall") + self.lbl_spatial_msg = QtWidgets.QLabel("") + self.lbl_spatial_msg.setProperty("class", "hint") + + bins_row = QtWidgets.QGridLayout() + bins_row.setHorizontalSpacing(6) + bins_row.setContentsMargins(0, 0, 0, 0) + bins_row.addWidget(QtWidgets.QLabel("X"), 0, 0) + bins_row.addWidget(self.spin_spatial_bins_x, 0, 1) + bins_row.addWidget(QtWidgets.QLabel("Y"), 0, 2) + bins_row.addWidget(self.spin_spatial_bins_y, 0, 3) + bins_widget = QtWidgets.QWidget() + bins_widget.setLayout(bins_row) + + clip_row = QtWidgets.QGridLayout() + clip_row.setHorizontalSpacing(6) + clip_row.setContentsMargins(0, 0, 0, 0) + clip_row.addWidget(QtWidgets.QLabel("Low"), 0, 0) + clip_row.addWidget(self.spin_spatial_clip_low, 0, 1) + clip_row.addWidget(QtWidgets.QLabel("High"), 0, 2) + clip_row.addWidget(self.spin_spatial_clip_high, 0, 3) + clip_widget = QtWidgets.QWidget() + clip_widget.setLayout(clip_row) + + time_row = QtWidgets.QGridLayout() + time_row.setHorizontalSpacing(6) + time_row.setContentsMargins(0, 0, 0, 0) + time_row.addWidget(QtWidgets.QLabel("Start"), 0, 0) + time_row.addWidget(self.spin_spatial_time_min, 0, 1) + time_row.addWidget(QtWidgets.QLabel("End"), 0, 2) + time_row.addWidget(self.spin_spatial_time_max, 0, 3) + time_widget = QtWidgets.QWidget() + time_widget.setLayout(time_row) + + help_row = QtWidgets.QHBoxLayout() + help_row.setContentsMargins(0, 0, 0, 0) + help_row.addStretch(1) + help_row.addWidget(self.btn_spatial_help) + help_widget = QtWidgets.QWidget() + help_widget.setLayout(help_row) + + f_spatial.addRow("X trajectory column", self.combo_spatial_x) + f_spatial.addRow("Y trajectory column", self.combo_spatial_y) + f_spatial.addRow("Help", help_widget) + f_spatial.addRow("Bins (X/Y)", bins_widget) + f_spatial.addRow("Occupancy map value", self.combo_spatial_weight) + f_spatial.addRow("Clip ranges", self.cb_spatial_clip) + f_spatial.addRow("Percentiles", clip_widget) + f_spatial.addRow("Time filter", self.cb_spatial_time_filter) + f_spatial.addRow("Time range (s)", time_widget) + f_spatial.addRow("Spatial smooth (bins)", self.spin_spatial_smooth) + f_spatial.addRow("Activity map mode", self.combo_spatial_activity_mode) + f_spatial.addRow("Log scale", self.cb_spatial_log) + f_spatial.addRow("Invert Y axis", self.cb_spatial_invert_y) + f_spatial.addRow("", self.btn_compute_spatial) + f_spatial.addRow("", self.lbl_spatial_msg) + + self.section_setup = QtWidgets.QWidget() + setup_layout = QtWidgets.QVBoxLayout(self.section_setup) + setup_layout.setContentsMargins(6, 6, 6, 6) + setup_layout.setSpacing(8) + setup_layout.addWidget(grp_src) + setup_layout.addWidget(grp_align) + setup_btn_row = QtWidgets.QHBoxLayout() + self.btn_setup_load = QtWidgets.QPushButton("Load") + self.btn_setup_load.setProperty("class", "compactPrimarySmall") + self.btn_setup_refresh = QtWidgets.QPushButton("Refresh A/D") + self.btn_setup_refresh.setProperty("class", "compactSmall") + setup_btn_row.addWidget(self.btn_setup_load) + setup_btn_row.addWidget(self.btn_setup_refresh) + setup_btn_row.addStretch(1) + setup_wrap = QtWidgets.QWidget() + setup_wrap.setLayout(setup_btn_row) + setup_layout.addWidget(setup_wrap) + setup_layout.addStretch(1) + + self.section_psth = QtWidgets.QWidget() + psth_layout = QtWidgets.QVBoxLayout(self.section_psth) + psth_layout.setContentsMargins(6, 6, 6, 6) + psth_layout.setSpacing(8) + psth_layout.addWidget(grp_opt) + psth_btn_row = QtWidgets.QHBoxLayout() + psth_btn_row.addWidget(self.btn_compute) + psth_btn_row.addWidget(self.btn_update) + psth_btn_row.addStretch(1) + psth_btn_wrap = QtWidgets.QWidget() + psth_btn_wrap.setLayout(psth_btn_row) + psth_layout.addWidget(psth_btn_wrap) + psth_layout.addStretch(1) + + self.section_signal = QtWidgets.QWidget() + signal_layout = QtWidgets.QVBoxLayout(self.section_signal) + signal_layout.setContentsMargins(6, 6, 6, 6) + signal_layout.setSpacing(8) + signal_layout.addWidget(grp_signal) + signal_layout.addWidget(self.tbl_signal_metrics) + signal_layout.addStretch(1) + + self.section_behavior = QtWidgets.QWidget() + behavior_layout = QtWidgets.QVBoxLayout(self.section_behavior) + behavior_layout.setContentsMargins(6, 6, 6, 6) + behavior_layout.setSpacing(8) + behavior_layout.addWidget(grp_behavior_analysis) + behavior_layout.addWidget(self.tbl_behavior_metrics) + behavior_layout.addWidget(self.lbl_behavior_summary) + behavior_layout.addStretch(1) + + self.section_spatial = QtWidgets.QWidget() + spatial_layout = QtWidgets.QVBoxLayout(self.section_spatial) + spatial_layout.setContentsMargins(6, 6, 6, 6) + spatial_layout.setSpacing(8) + spatial_layout.addWidget(grp_spatial) + spatial_layout.addStretch(1) + + self.section_export = QtWidgets.QWidget() + export_layout = QtWidgets.QVBoxLayout(self.section_export) + export_layout.setContentsMargins(6, 6, 6, 6) + export_layout.setSpacing(8) + export_layout.addWidget(self.btn_export) + export_layout.addWidget(self.btn_export_img) + export_layout.addWidget(self.btn_save_cfg) + export_layout.addWidget(self.btn_load_cfg) + export_layout.addWidget(self.btn_new_project) + export_layout.addWidget(self.btn_save_project) + export_layout.addWidget(self.btn_load_project) + export_layout.addStretch(1) + + action_row = QtWidgets.QHBoxLayout() + action_row.setContentsMargins(0, 0, 0, 0) + action_row.setSpacing(6) + + self.btn_action_load = QtWidgets.QPushButton("File") + self.btn_action_load.setProperty("class", "compactPrimarySmall") + self.menu_action_load = QtWidgets.QMenu(self.btn_action_load) + self.act_load_current = self.menu_action_load.addAction("Use current preprocessed selection") + self.act_load_single = self.menu_action_load.addAction("Load processed file (single)") + self.act_load_group = self.menu_action_load.addAction("Load processed files (group)") + self.menu_action_load.addSeparator() + self.act_new_project = self.menu_action_load.addAction("New project") + self.act_save_project = self.menu_action_load.addAction("Save project (.h5)") + self.act_load_project = self.menu_action_load.addAction("Load project (.h5)") + self.menu_action_load.addSeparator() + self.act_load_behavior = self.menu_action_load.addAction("Load behavior CSV/XLSX") + self.menu_action_recent = self.menu_action_load.addMenu("Load recent") + self.menu_recent_processed = self.menu_action_recent.addMenu("Processed files") + self.menu_recent_behavior = self.menu_action_recent.addMenu("Behavior files") + self.menu_recent_projects = self.menu_action_recent.addMenu("Projects") + self.menu_action_recent.aboutToShow.connect(self._refresh_recent_postprocessing_menus) + self.act_refresh_dio = self.menu_action_load.addAction("Refresh A/D channel list") + self.menu_action_load.addSeparator() + self.act_open_plot_style = self.menu_action_load.addAction("Plot style...") + self.btn_action_load.setMenu(self.menu_action_load) + + self.btn_action_compute = QtWidgets.QPushButton("Compute PSTH") + self.btn_action_compute.setProperty("class", "compactPrimarySmall") + self.btn_action_export = QtWidgets.QPushButton("Export") + self.btn_action_export.setProperty("class", "compactPrimarySmall") + self.btn_action_hide = QtWidgets.QPushButton("Hide Panels") + self.btn_action_hide.setProperty("class", "compactSmall") + + self.btn_panel_setup = QtWidgets.QPushButton("Setup") + self.btn_panel_psth = QtWidgets.QPushButton("PSTH") + self.btn_panel_spatial = QtWidgets.QPushButton("Spatial") + self.btn_panel_export = QtWidgets.QPushButton("Export") + self.btn_panel_signal = QtWidgets.QPushButton("Signal") + self.btn_panel_behavior = QtWidgets.QPushButton("Behavior") + self._section_buttons = { + "setup": self.btn_panel_setup, + "psth": self.btn_panel_psth, + "spatial": self.btn_panel_spatial, + "export": self.btn_panel_export, + "signal": self.btn_panel_signal, + "behavior": self.btn_panel_behavior, + } + for b in self._section_buttons.values(): + b.setCheckable(True) + b.setProperty("class", "compactSmall") - lv.addWidget(grp_src) - lv.addWidget(grp_align) - lv.addWidget(grp_opt) - lv.addWidget(self.btn_compute) - lv.addWidget(self.btn_update) - lv.addWidget(self.btn_export) - lv.addWidget(self.btn_export_img) - lv.addWidget(self.btn_style) - lv.addWidget(self.btn_save_cfg) - lv.addWidget(self.btn_load_cfg) - lv.addStretch(1) - - scroll = QtWidgets.QScrollArea() - scroll.setWidgetResizable(True) - scroll.setWidget(left) + action_row.addWidget(self.btn_action_load) + action_row.addWidget(self.btn_action_compute) + action_row.addWidget(self.btn_action_export) + action_row.addWidget(self.btn_action_hide) + action_row.addWidget(self.btn_style) + action_row.addSpacing(8) + action_row.addWidget(QtWidgets.QLabel("Panels:")) + action_row.addWidget(self.btn_panel_setup) + action_row.addWidget(self.btn_panel_psth) + action_row.addWidget(self.btn_panel_spatial) + action_row.addWidget(self.btn_panel_export) + action_row.addWidget(self.btn_panel_signal) + action_row.addWidget(self.btn_panel_behavior) + action_row.addStretch(1) + root.addLayout(action_row) # Right plots: trace preview + heatmap + avg right = QtWidgets.QWidget() self._right_panel = right + right.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding) rv = QtWidgets.QVBoxLayout(right) rv.setSpacing(10) - self.plot_trace = pg.PlotWidget(title="Trace preview (events as vertical lines)") - self.plot_heat = pg.PlotWidget(title="Heatmap (trials or recordings)") + rv.setContentsMargins(0, 0, 0, 0) + rv.setSpacing(8) + + header_row = QtWidgets.QHBoxLayout() + self.lbl_plot_file = QtWidgets.QLabel("File: (none)") + header_font = self.lbl_plot_file.font() + header_font.setBold(True) + self.lbl_plot_file.setFont(header_font) + header_row.addWidget(self.lbl_plot_file) + header_row.addStretch(1) + rv.addLayout(header_row) + + view_row = QtWidgets.QHBoxLayout() + view_row.addWidget(QtWidgets.QLabel("View layout")) + self.combo_view_layout = QtWidgets.QComboBox() + self.combo_view_layout.addItems(["Standard", "Heatmap focus", "Trace focus", "Metrics focus", "All"]) + _compact_combo(self.combo_view_layout, min_chars=9) + view_row.addWidget(self.combo_view_layout) + view_row.addStretch(1) + rv.addLayout(view_row) + + self.plot_trace = pg.PlotWidget(title="Trace preview") + self.plot_heat = pg.PlotWidget(title="Heatmap") self.plot_dur = pg.PlotWidget(title="Event duration") - self.plot_avg = pg.PlotWidget(title="Average PSTH ± SEM") - self.plot_metrics = pg.PlotWidget(title="Metrics (pre vs post)") + self.plot_avg = pg.PlotWidget(title="Average PSTH +/- SEM") + self.plot_metrics = pg.PlotWidget(title="PSTH metrics") self.plot_global = pg.PlotWidget(title="Global metrics") + self.plot_peak_amp = pg.PlotWidget(title="Peak amplitudes") + self.plot_peak_ibi = pg.PlotWidget(title="Inter-peak intervals") + self.plot_peak_rate = pg.PlotWidget(title="Peak rate over time") + self.plot_behavior_raster = pg.PlotWidget(title="Behavior raster") + self.plot_behavior_rate = pg.PlotWidget(title="Behavior frequency over time") + self.plot_behavior_duration = pg.PlotWidget(title="Behavior duration distribution") + self.plot_behavior_starts = pg.PlotWidget(title="Behavior start times") + self.plot_spatial_occupancy = pg.PlotWidget(title="Spatial occupancy") + self.plot_spatial_activity = pg.PlotWidget(title="Spatial activity (mean z-score)") + self.plot_spatial_velocity = pg.PlotWidget(title="Spatial velocity (mean speed)") - for w in (self.plot_trace, self.plot_heat, self.plot_dur, self.plot_avg, self.plot_metrics, self.plot_global): + for w in ( + self.plot_trace, + self.plot_heat, + self.plot_dur, + self.plot_avg, + self.plot_metrics, + self.plot_global, + self.plot_peak_amp, + self.plot_peak_ibi, + self.plot_peak_rate, + self.plot_behavior_raster, + self.plot_behavior_rate, + self.plot_behavior_duration, + self.plot_behavior_starts, + self.plot_spatial_occupancy, + self.plot_spatial_activity, + self.plot_spatial_velocity, + ): _opt_plot(w) self.curve_trace = self.plot_trace.plot(pen=pg.mkPen(self._style["trace"], width=1.1)) self.curve_behavior = self.plot_trace.plot(pen=pg.mkPen(self._style["behavior"], width=1.0)) + self.curve_peak_markers = self.plot_trace.plot( + pen=None, + symbol="o", + symbolSize=6, + symbolBrush=pg.mkBrush(240, 120, 80), + symbolPen=pg.mkPen((240, 120, 80), width=1.0), + ) self.event_lines: List[pg.InfiniteLine] = [] self.img = pg.ImageItem() self.plot_heat.addItem(self.img) + self.heat_zero_line = pg.InfiniteLine( + pos=0.0, + angle=90, + movable=False, + pen=pg.mkPen((245, 245, 245), width=1.0, style=QtCore.Qt.PenStyle.DotLine), + ) + self.heat_zero_line.setZValue(20) + self.plot_heat.addItem(self.heat_zero_line) + self.heat_lut = pg.HistogramLUTWidget() + self.heat_lut.setMinimumWidth(110) + self.heat_lut.setMaximumWidth(150) + self.heat_lut.setImageItem(self.img) self.plot_heat.setLabel("bottom", "Time (s)") self.plot_heat.setLabel("left", "Trials / Recordings") self.plot_dur.setLabel("bottom", "Duration (s)") self.plot_dur.setLabel("left", "Count") + self.plot_peak_amp.setLabel("bottom", "Amplitude") + self.plot_peak_amp.setLabel("left", "Count") + self.plot_peak_ibi.setLabel("bottom", "Interval (s)") + self.plot_peak_ibi.setLabel("left", "Count") + self.plot_peak_rate.setLabel("bottom", "Time (s)") + self.plot_peak_rate.setLabel("left", "Peaks/min") + self.plot_behavior_raster.setLabel("bottom", "Time (s)") + self.plot_behavior_raster.setLabel("left", "File index") + self.plot_behavior_rate.setLabel("bottom", "Time (s)") + self.plot_behavior_rate.setLabel("left", "Events/min") + self.plot_behavior_duration.setLabel("bottom", "Duration (s)") + self.plot_behavior_duration.setLabel("left", "Count") + self.plot_behavior_starts.setLabel("bottom", "Start time (s)") + self.plot_behavior_starts.setLabel("left", "Count") + self.plot_spatial_occupancy.setLabel("bottom", "X") + self.plot_spatial_occupancy.setLabel("left", "Y") + self.plot_spatial_activity.setLabel("bottom", "X") + self.plot_spatial_activity.setLabel("left", "Y") + self.plot_spatial_velocity.setLabel("bottom", "X") + self.plot_spatial_velocity.setLabel("left", "Y") + self.img_spatial_occupancy = pg.ImageItem() + self.img_spatial_activity = pg.ImageItem() + self.img_spatial_velocity = pg.ImageItem() + self.plot_spatial_occupancy.addItem(self.img_spatial_occupancy) + self.plot_spatial_activity.addItem(self.img_spatial_activity) + self.plot_spatial_velocity.addItem(self.img_spatial_velocity) + self.spatial_lut_occupancy = pg.HistogramLUTWidget() + self.spatial_lut_activity = pg.HistogramLUTWidget() + self.spatial_lut_velocity = pg.HistogramLUTWidget() + self.spatial_lut_occupancy.setMinimumWidth(110) + self.spatial_lut_occupancy.setMaximumWidth(150) + self.spatial_lut_activity.setMinimumWidth(110) + self.spatial_lut_activity.setMaximumWidth(150) + self.spatial_lut_velocity.setMinimumWidth(110) + self.spatial_lut_velocity.setMaximumWidth(150) + self.spatial_lut_occupancy.setImageItem(self.img_spatial_occupancy) + self.spatial_lut_activity.setImageItem(self.img_spatial_activity) + self.spatial_lut_velocity.setImageItem(self.img_spatial_velocity) self.curve_avg = self.plot_avg.plot(pen=pg.mkPen(self._style["avg"], width=1.3)) - self.curve_sem_hi = self.plot_avg.plot(pen=pg.mkPen((220, 220, 220), width=1.0)) - self.curve_sem_lo = self.plot_avg.plot(pen=pg.mkPen((220, 220, 220), width=1.0)) + self.curve_sem_hi = self.plot_avg.plot(pen=pg.mkPen((152, 201, 143), width=1.0)) + self.curve_sem_lo = self.plot_avg.plot(pen=pg.mkPen((152, 201, 143), width=1.0)) + self.sem_band = pg.FillBetweenItem( + self.curve_sem_hi, + self.curve_sem_lo, + brush=pg.mkBrush(188, 230, 178, 96), + ) + self.plot_avg.addItem(self.sem_band) self.plot_avg.addLine(x=0, pen=pg.mkPen((200, 200, 200), style=QtCore.Qt.PenStyle.DashLine)) self.metrics_bar_pre = pg.BarGraphItem(x=[0], height=[0], width=0.6, brush=(90, 143, 214)) self.metrics_bar_post = pg.BarGraphItem(x=[1], height=[0], width=0.6, brush=(214, 122, 90)) self.plot_metrics.addItem(self.metrics_bar_pre) self.plot_metrics.addItem(self.metrics_bar_post) + # Overlay paired trial/event points (pre vs post) and links. + self.metrics_pairs_curve = self.plot_metrics.plot( + pen=pg.mkPen((210, 215, 225, 130), width=1.0), + connect="finite", + skipFiniteCheck=True, + ) + self.metrics_scatter_pre = self.plot_metrics.plot( + pen=None, + symbol="o", + symbolSize=5, + symbolBrush=pg.mkBrush(90, 143, 214, 220), + symbolPen=pg.mkPen((90, 143, 214), width=0.8), + ) + self.metrics_scatter_post = self.plot_metrics.plot( + pen=None, + symbol="o", + symbolSize=5, + symbolBrush=pg.mkBrush(214, 122, 90, 220), + symbolPen=pg.mkPen((214, 122, 90), width=0.8), + ) + self.metrics_err_pre = pg.ErrorBarItem( + x=np.array([0.0], float), + y=np.array([0.0], float), + top=np.array([0.0], float), + bottom=np.array([0.0], float), + beam=0.22, + pen=pg.mkPen((230, 236, 246), width=2.0), + ) + self.metrics_err_post = pg.ErrorBarItem( + x=np.array([1.0], float), + y=np.array([0.0], float), + top=np.array([0.0], float), + bottom=np.array([0.0], float), + beam=0.22, + pen=pg.mkPen((230, 236, 246), width=2.0), + ) + self.plot_metrics.addItem(self.metrics_err_pre) + self.plot_metrics.addItem(self.metrics_err_post) self.plot_metrics.setXRange(-0.5, 1.5, padding=0) self.plot_metrics.getAxis("bottom").setTicks([[(0, "pre"), (1, "post")]]) @@ -715,59 +1569,213 @@ def _build_ui(self) -> None: self.global_bar_freq = pg.BarGraphItem(x=[1], height=[0], width=0.6, brush=(220, 160, 120)) self.plot_global.addItem(self.global_bar_amp) self.plot_global.addItem(self.global_bar_freq) + self.global_scatter_amp = self.plot_global.plot( + pen=None, + symbol="o", + symbolSize=6, + symbolBrush=pg.mkBrush(120, 180, 220, 220), + symbolPen=pg.mkPen((120, 180, 220), width=0.9), + ) + self.global_scatter_freq = self.plot_global.plot( + pen=None, + symbol="o", + symbolSize=6, + symbolBrush=pg.mkBrush(220, 160, 120, 220), + symbolPen=pg.mkPen((220, 160, 120), width=0.9), + ) + self.global_err_amp = pg.ErrorBarItem( + x=np.array([0.0], float), + y=np.array([0.0], float), + top=np.array([0.0], float), + bottom=np.array([0.0], float), + beam=0.22, + pen=pg.mkPen((230, 236, 246), width=2.0), + ) + self.global_err_freq = pg.ErrorBarItem( + x=np.array([1.0], float), + y=np.array([0.0], float), + top=np.array([0.0], float), + bottom=np.array([0.0], float), + beam=0.22, + pen=pg.mkPen((230, 236, 246), width=2.0), + ) + self.plot_global.addItem(self.global_err_amp) + self.plot_global.addItem(self.global_err_freq) self.plot_global.setXRange(-0.5, 1.5, padding=0) self.plot_global.getAxis("bottom").setTicks([[(0, "amp"), (1, "freq")]]) - heat_row = QtWidgets.QHBoxLayout() + self.row_heat = QtWidgets.QWidget() + heat_row = QtWidgets.QHBoxLayout(self.row_heat) + heat_row.setContentsMargins(0, 0, 0, 0) + heat_row.setSpacing(8) heat_row.addWidget(self.plot_heat, stretch=4) + heat_row.addWidget(self.heat_lut, stretch=0) heat_row.addWidget(self.plot_dur, stretch=1) - avg_row = QtWidgets.QHBoxLayout() + self.row_avg = QtWidgets.QWidget() + avg_row = QtWidgets.QHBoxLayout(self.row_avg) + avg_row.setContentsMargins(0, 0, 0, 0) + avg_row.setSpacing(8) avg_row.addWidget(self.plot_avg, stretch=4) avg_row.addWidget(self.plot_metrics, stretch=1) avg_row.addWidget(self.plot_global, stretch=1) + self.row_signal = QtWidgets.QWidget() + signal_row = QtWidgets.QHBoxLayout(self.row_signal) + signal_row.setContentsMargins(0, 0, 0, 0) + signal_row.setSpacing(8) + signal_row.addWidget(self.plot_peak_amp, stretch=1) + signal_row.addWidget(self.plot_peak_ibi, stretch=1) + signal_row.addWidget(self.plot_peak_rate, stretch=1) + + self.row_behavior = QtWidgets.QWidget() + behavior_grid = QtWidgets.QGridLayout(self.row_behavior) + behavior_grid.setContentsMargins(0, 0, 0, 0) + behavior_grid.setHorizontalSpacing(8) + behavior_grid.setVerticalSpacing(8) + behavior_grid.addWidget(self.plot_behavior_raster, 0, 0, 1, 3) + behavior_grid.addWidget(self.plot_behavior_rate, 1, 0) + behavior_grid.addWidget(self.plot_behavior_duration, 1, 1) + behavior_grid.addWidget(self.plot_behavior_starts, 1, 2) + behavior_grid.setColumnStretch(0, 1) + behavior_grid.setColumnStretch(1, 1) + behavior_grid.setColumnStretch(2, 1) + + self.spatial_plot_dialog = QtWidgets.QDialog(self) + self.spatial_plot_dialog.setWindowTitle("Spatial") + self.spatial_plot_dialog.setModal(False) + self.spatial_plot_dialog.resize(980, 920) + spatial_dialog_layout = QtWidgets.QVBoxLayout(self.spatial_plot_dialog) + spatial_dialog_layout.setContentsMargins(8, 8, 8, 8) + spatial_dialog_layout.setSpacing(8) + self.spatial_plot_content = QtWidgets.QWidget(self.spatial_plot_dialog) + spatial_content_layout = QtWidgets.QVBoxLayout(self.spatial_plot_content) + spatial_content_layout.setContentsMargins(0, 0, 0, 0) + spatial_content_layout.setSpacing(8) + spatial_top_row = QtWidgets.QHBoxLayout() + spatial_top_row.setContentsMargins(0, 0, 0, 0) + spatial_top_row.setSpacing(8) + spatial_top_row.addWidget(self.plot_spatial_occupancy, stretch=1) + spatial_top_row.addWidget(self.spatial_lut_occupancy, stretch=0) + spatial_bottom_row = QtWidgets.QHBoxLayout() + spatial_bottom_row.setContentsMargins(0, 0, 0, 0) + spatial_bottom_row.setSpacing(8) + spatial_bottom_row.addWidget(self.plot_spatial_activity, stretch=1) + spatial_bottom_row.addWidget(self.spatial_lut_activity, stretch=0) + spatial_velocity_row = QtWidgets.QHBoxLayout() + spatial_velocity_row.setContentsMargins(0, 0, 0, 0) + spatial_velocity_row.setSpacing(8) + spatial_velocity_row.addWidget(self.plot_spatial_velocity, stretch=1) + spatial_velocity_row.addWidget(self.spatial_lut_velocity, stretch=0) + spatial_content_layout.addLayout(spatial_top_row, stretch=1) + spatial_content_layout.addLayout(spatial_bottom_row, stretch=1) + spatial_content_layout.addLayout(spatial_velocity_row, stretch=1) + self.lbl_spatial_cursor_hint = QtWidgets.QLabel("Use the right-side color cursors to set min/max display range for each map.") + self.lbl_spatial_cursor_hint.setProperty("class", "hint") + spatial_content_layout.addWidget(self.lbl_spatial_cursor_hint) + spatial_dialog_layout.addWidget(self.spatial_plot_content, stretch=1) + self.btn_export_spatial_img = QtWidgets.QPushButton("Export image") + self.btn_export_spatial_img.setProperty("class", "compactSmall") + spatial_export_row = QtWidgets.QHBoxLayout() + spatial_export_row.setContentsMargins(0, 0, 0, 0) + spatial_export_row.addStretch(1) + spatial_export_row.addWidget(self.btn_export_spatial_img) + spatial_dialog_layout.addLayout(spatial_export_row) + self.spatial_plot_dialog.hide() + + # Keep a visible minimum plot footprint even with aggressive docking/resizing. + self.plot_trace.setMinimumHeight(140) + self.row_heat.setMinimumHeight(180) + self.row_avg.setMinimumHeight(140) + rv.addWidget(self.plot_trace, stretch=1) - rv.addLayout(heat_row, stretch=2) - rv.addLayout(avg_row, stretch=1) - self.lbl_log = QtWidgets.QLabel("") - self.lbl_log.setProperty("class", "hint") - rv.addWidget(self.lbl_log) - - # Layout - splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) - splitter.addWidget(scroll) - splitter.addWidget(right) - splitter.setStretchFactor(0, 0) - splitter.setStretchFactor(1, 1) - splitter.setSizes([260, 1240]) - - root.addWidget(splitter) + rv.addWidget(self.row_heat, stretch=2) + rv.addWidget(self.row_avg, stretch=1) + rv.addWidget(self.row_signal, stretch=1) + rv.addWidget(self.row_behavior, stretch=1) + if self._use_pg_dockarea_layout: + workspace = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + workspace.setChildrenCollapsible(False) + workspace.addWidget(right) + self._dockarea = DockArea() + self._dockarea.setMinimumWidth(_POST_RIGHT_PANEL_MIN_WIDTH) + workspace.addWidget(self._dockarea) + workspace.setStretchFactor(0, 5) + workspace.setStretchFactor(1, 3) + workspace.setSizes([1280, _POST_RIGHT_PANEL_MIN_WIDTH + 40]) + self._dockarea_splitter = workspace + root.addWidget(workspace, stretch=1) + else: + root.addWidget(right, stretch=1) + root.setStretch(0, 0) + root.setStretch(1, 1) + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + else: + self._setup_section_popups() + self._apply_widget_theme_mode() # Wiring + self.act_load_current.triggered.connect(self.requestCurrentProcessed.emit) + self.act_load_single.triggered.connect(self._load_processed_files_single) + self.act_load_group.triggered.connect(self._load_processed_files) + self.act_new_project.triggered.connect(self._new_project) + self.act_save_project.triggered.connect(self._save_project_file) + self.act_load_project.triggered.connect(self._load_project_file) + self.act_load_behavior.triggered.connect(self._load_behavior_files) + self.act_refresh_dio.triggered.connect(self.requestDioList.emit) + self.act_open_plot_style.triggered.connect(self._open_style_dialog) + self.btn_action_compute.clicked.connect(self._compute_psth) + self.btn_action_export.clicked.connect(self._export_results) + self.btn_action_hide.clicked.connect(self._hide_all_section_popups) + for key, btn in self._section_buttons.items(): + btn.toggled.connect(lambda checked, section_key=key: self._toggle_section_popup(section_key, checked)) + self.btn_use_current.clicked.connect(self.requestCurrentProcessed.emit) self.btn_refresh_dio.clicked.connect(self.requestDioList.emit) + self.btn_setup_refresh.clicked.connect(self.requestDioList.emit) + self.btn_setup_load.clicked.connect(self._load_processed_files) self.btn_load_beh.clicked.connect(self._load_behavior_files) self.btn_load_processed.clicked.connect(self._load_processed_files) self.btn_load_processed_single.clicked.connect(self._load_processed_files_single) self.list_preprocessed.filesDropped.connect(self._on_preprocessed_files_dropped) self.list_preprocessed.orderChanged.connect(self._sync_processed_order_from_list) + self.list_preprocessed.itemSelectionChanged.connect(self._compute_spatial_heatmap) self.list_behaviors.filesDropped.connect(self._on_behavior_files_dropped) self.list_behaviors.orderChanged.connect(self._sync_behavior_order_from_list) + self.list_behaviors.itemSelectionChanged.connect(self._compute_spatial_heatmap) self.btn_compute.clicked.connect(self._compute_psth) self.btn_update.clicked.connect(self._compute_psth) + self.btn_detect_peaks.clicked.connect(self._detect_signal_events) + self.btn_export_peaks.clicked.connect(self._export_signal_events_csv) + self.btn_compute_behavior.clicked.connect(self._compute_behavior_analysis) + self.btn_export_behavior_metrics.clicked.connect(self._export_behavior_metrics_csv) + self.btn_export_behavior_events.clicked.connect(self._export_behavior_events_csv) + self.btn_compute_spatial.clicked.connect(self._on_compute_spatial_clicked) + self.btn_export_spatial_img.clicked.connect(self._export_spatial_figure) self.btn_export.clicked.connect(self._export_results) self.btn_export_img.clicked.connect(self._export_images) self.btn_style.clicked.connect(self._open_style_dialog) self.btn_save_cfg.clicked.connect(self._save_config_file) self.btn_load_cfg.clicked.connect(self._load_config_file) + self.btn_new_project.clicked.connect(self._new_project) + self.btn_save_project.clicked.connect(self._save_project_file) + self.btn_load_project.clicked.connect(self._load_project_file) + self.btn_apply_behavior_time.clicked.connect(self._apply_behavior_time_settings) self.cb_filter_events.stateChanged.connect(self._update_event_filter_enabled) self.cb_metrics.stateChanged.connect(self._update_metrics_enabled) self.cb_global_metrics.stateChanged.connect(self._update_global_metrics_enabled) self.btn_hide_filters.toggled.connect(self._toggle_filter_panel) self.btn_hide_metrics.toggled.connect(self._toggle_metrics_panel) + self.combo_view_layout.currentIndexChanged.connect(self._apply_view_layout) + self.combo_view_layout.currentIndexChanged.connect(self._queue_settings_save) + self.cb_peak_overlay.toggled.connect(self._refresh_signal_overlay) + self.combo_signal_source.currentIndexChanged.connect(self._refresh_signal_file_combo) + self.combo_signal_scope.currentIndexChanged.connect(self._refresh_signal_file_combo) + self.tab_sources.currentChanged.connect(self._refresh_signal_file_combo) self.combo_align.currentIndexChanged.connect(self._update_align_ui) + self.combo_behavior_file_type.currentIndexChanged.connect(self._update_align_ui) self.combo_behavior_align.currentIndexChanged.connect(self._update_align_ui) self.combo_align.currentIndexChanged.connect(self._refresh_behavior_list) self.combo_align.currentIndexChanged.connect(self._compute_psth) @@ -785,6 +1793,7 @@ def _build_ui(self) -> None: for w in ( self.spin_event_start, self.spin_event_end, + self.spin_group_window, self.spin_dur_min, self.spin_dur_max, self.spin_metric_pre0, @@ -800,164 +1809,1339 @@ def _build_ui(self) -> None: self.cb_global_freq.stateChanged.connect(self._compute_psth) for w in (self.spin_metric_pre0, self.spin_metric_pre1, self.spin_metric_post0, self.spin_metric_post1): w.valueChanged.connect(self._update_metric_regions) - + for w in (self.combo_spatial_x, self.combo_spatial_y, self.combo_spatial_weight): + w.currentIndexChanged.connect(self._compute_spatial_heatmap) + for w in ( + self.spin_spatial_bins_x, + self.spin_spatial_bins_y, + self.spin_spatial_clip_low, + self.spin_spatial_clip_high, + self.spin_spatial_time_min, + self.spin_spatial_time_max, + self.spin_spatial_smooth, + ): + w.valueChanged.connect(self._compute_spatial_heatmap) + self.cb_spatial_clip.toggled.connect(self._compute_spatial_heatmap) + self.cb_spatial_clip.toggled.connect(self._update_spatial_clip_enabled) + self.cb_spatial_time_filter.toggled.connect(self._compute_spatial_heatmap) + self.cb_spatial_time_filter.toggled.connect(self._update_spatial_time_filter_enabled) + self.cb_spatial_log.toggled.connect(self._compute_spatial_heatmap) + self.cb_spatial_invert_y.toggled.connect(self._compute_spatial_heatmap) + self.combo_spatial_activity_mode.currentIndexChanged.connect(self._compute_spatial_heatmap) + self.btn_spatial_help.clicked.connect(self._show_spatial_help) + if hasattr(self, "heat_lut") and getattr(self.heat_lut, "item", None) is not None: + level_signal = getattr(self.heat_lut.item, "sigLevelChangeFinished", None) + if level_signal is None: + level_signal = getattr(self.heat_lut.item, "sigLevelsChangeFinished", None) + if level_signal is not None: + level_signal.connect(self._on_heatmap_levels_changed) + self._wire_settings_autosave() + + self._apply_plot_style() self._update_align_ui() self._update_event_filter_enabled() self._update_metrics_enabled() self._update_global_metrics_enabled() self._toggle_filter_panel(False) self._toggle_metrics_panel(False) + self._apply_view_layout() + self._refresh_signal_file_combo() + self._update_data_availability() + self._update_spatial_clip_enabled() + self._update_spatial_time_filter_enabled() + self._update_behavior_time_panel() + self._refresh_spatial_columns() + self._compute_spatial_heatmap() + self._update_status_strip() QtGui.QShortcut(QtGui.QKeySequence("Ctrl+S"), self, activated=self._export_results) QtGui.QShortcut(QtGui.QKeySequence("F5"), self, activated=self._compute_psth) - # ---- bridge reception ---- + def _dock_main_window(self) -> Optional[QtWidgets.QMainWindow]: + host = self.window() + return host if isinstance(host, QtWidgets.QMainWindow) else None + + def _normalize_app_theme_mode(self, value: object) -> str: + mode = str(value or "").strip().lower() + if mode in {"light", "white", "l", "w"}: + return "light" + return "dark" + + def _scroll_background_color(self) -> str: + if self._app_theme_mode == "light": + return "#f6f8fc" + return "#242a34" + + def _apply_scroll_theme(self, scroll: QtWidgets.QScrollArea) -> None: + bg = self._scroll_background_color() + scroll.setStyleSheet(f"QScrollArea {{ background: {bg}; border: none; }}") + # Keep viewport painted with dock background so dynamic row visibility + # does not produce unstyled gaps. + scroll.viewport().setAutoFillBackground(True) + scroll.viewport().setStyleSheet(f"background: {bg};") + + def _apply_widget_theme_mode(self) -> None: + if hasattr(self, "tab_sources"): + self.tab_sources.setStyleSheet( + "QTabWidget::pane { border: 0px; background: transparent; padding: 0px; }" + "QTabBar::tab { margin-right: 0px; }" + ) + for scroll in self._section_scroll_hosts.values(): + try: + self._apply_scroll_theme(scroll) + except Exception: + continue - def set_current_source_label(self, filename: str, channel: str) -> None: - self.lbl_current.setText(f"Current: {filename} [{channel}]") + def set_app_theme_mode(self, theme_mode: object) -> None: + self._app_theme_mode = self._normalize_app_theme_mode(theme_mode) + self._apply_widget_theme_mode() + self._style["plot_bg"] = (248, 250, 255) if self._app_theme_mode == "light" else (36, 42, 52) + try: + self._apply_plot_style() + except Exception: + pass - def notify_preprocessing_updated(self, _processed: ProcessedTrial) -> None: - # no-op; user presses compute or update - pass + def _section_widget_map(self) -> Dict[str, Tuple[str, QtWidgets.QWidget]]: + return { + "setup": ("Setup", self.section_setup), + "psth": ("PSTH", self.section_psth), + "spatial": ("Spatial", self.section_spatial), + "export": ("Export", self.section_export), + "signal": ("Signal Event Analyzer", self.section_signal), + "behavior": ("Behavior Analysis", self.section_behavior), + } - def _set_resample_from_processed(self) -> None: - if not self._processed: + def _setup_dockarea_sections(self) -> None: + if not self._use_pg_dockarea_layout: return - proc = self._processed[0] - fs = float(proc.fs_used) if np.isfinite(proc.fs_used) else float(proc.fs_actual) - if not np.isfinite(fs) or fs <= 0: - t = np.asarray(proc.time, float) if proc.time is not None else np.array([], float) - if t.size > 2: - dt = np.nanmedian(np.diff(t)) - fs = 1.0 / float(dt) if np.isfinite(dt) and dt > 0 else np.nan - if np.isfinite(fs) and fs > 0: - fs = max(1.0, min(1000.0, float(fs))) - self.spin_resample.setValue(fs) + if self._dockarea is None: + return + if self._dockarea_docks: + return + for key, (title, widget) in self._section_widget_map().items(): + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self._apply_scroll_theme(scroll) + scroll.setMinimumWidth(_POST_RIGHT_PANEL_MIN_WIDTH) + widget.setMinimumSize(0, 0) + widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) + scroll.setWidget(widget) + self._section_scroll_hosts[key] = scroll + dock = Dock( + title, + area=self._dockarea, + closable=False if key in _FIXED_POST_RIGHT_SECTIONS else True, + ) + dock.setObjectName(f"post.da.{key}.dock") + dock.addWidget(scroll) + self._lock_pg_dock_interactions(dock) + try: + dock.sigClosed.connect(lambda *_, section_key=key: self._on_dockarea_dock_closed(section_key)) + except Exception: + pass + self._dockarea_docks[key] = dock + if self._force_fixed_default_layout: + self._apply_fixed_dockarea_layout() + else: + self._restore_dockarea_layout_state() + self._dock_layout_restored = True + host = self._dock_main_window() + if host is not None: + self._dock_host = host + try: + if hasattr(host, "on_post_docks_ready"): + host.on_post_docks_ready() + elif hasattr(host, "onPostDocksReady"): + host.onPostDocksReady() + except Exception: + pass - @QtCore.Slot(list) - def receive_current_processed(self, processed_list: List[ProcessedTrial]) -> None: - self._processed = processed_list or [] - # update trace preview with first entry - self._refresh_behavior_list() - self._set_resample_from_processed() - self._update_trace_preview() + def _dockarea_dock(self, key: str) -> Optional[Dock]: + return self._dockarea_docks.get(key) - def append_processed(self, processed_list: List[ProcessedTrial]) -> None: - if not processed_list: + def _dockarea_default_visibility_map(self) -> Dict[str, bool]: + visible_map = {key: False for key in self._dockarea_docks.keys()} + for key in _FIXED_POST_RIGHT_TAB_ORDER: + if key in visible_map: + visible_map[key] = True + return visible_map + + def _lock_pg_dock_interactions(self, dock: Dock) -> None: + label = getattr(dock, "label", None) + if label is None: + return + if not self._force_fixed_default_layout: + self._style_pg_dock_label_buttons(dock, label) + return + if bool(getattr(label, "_pyber_fixed_interaction_lock", False)): return - self._processed.extend(processed_list) - self._refresh_behavior_list() - self._set_resample_from_processed() - self._update_trace_preview() - @QtCore.Slot(list) - def receive_dio_list(self, dio_list: List[str]) -> None: - self.combo_dio.clear() - for d in dio_list or []: - self.combo_dio.addItem(d) + def _ignore_drag(event: QtGui.QMouseEvent) -> None: + event.ignore() - @QtCore.Slot(str, str, object, object) - def receive_dio_data(self, path: str, dio_name: str, t: Optional[np.ndarray], x: Optional[np.ndarray]) -> None: - if t is None or x is None: - return - self._dio_cache[(path, dio_name)] = (np.asarray(t, float), np.asarray(x, float)) + def _ignore_double_click(event: QtGui.QMouseEvent) -> None: + event.accept() - def _load_behavior_paths(self, paths: List[str], replace: bool) -> None: - if replace: - self._behavior_sources.clear() - for p in paths: - stem = os.path.splitext(os.path.basename(p))[0] - ext = os.path.splitext(p)[1].lower() + try: + label.mouseMoveEvent = _ignore_drag + label.mouseDoubleClickEvent = _ignore_double_click + label.setCursor(QtCore.Qt.CursorShape.ArrowCursor) + label._pyber_fixed_interaction_lock = True + except Exception: + pass + self._style_pg_dock_label_buttons(dock, label) + + def _style_pg_dock_label_buttons(self, dock: Dock, label: object) -> None: + if label is None: + return + try: + buttons = label.findChildren(QtWidgets.QToolButton) + except Exception: + buttons = [] + for btn in buttons: try: - if ext == ".csv": - info = _load_behavior_csv(p) - elif ext == ".xlsx": - import pandas as pd - xls = pd.ExcelFile(p, engine="openpyxl") - sheet = None - if len(xls.sheet_names) > 1: - sheet, ok = QtWidgets.QInputDialog.getItem( - self, - "Select sheet", - f"{os.path.basename(p)}: choose sheet", - xls.sheet_names, - 0, - False, - ) - if not ok: - continue - info = _load_behavior_ethovision(p, sheet_name=sheet) - else: - continue - self._behavior_sources[stem] = info - except Exception as exc: - QtWidgets.QMessageBox.warning( - self, - "Behavior load failed", - f"Could not load {os.path.basename(p)}:\n{exc}", + btn.setText("x") + btn.setIcon(QtGui.QIcon()) + btn.setAutoRaise(True) + btn.setFixedSize(13, 13) + btn.setToolTip("Close") + if not bool(btn.property("_pyber_hide_wired")): + try: + btn.clicked.disconnect() + except Exception: + pass + btn.clicked.connect(lambda _checked=False, section_dock=dock: self._hide_dockarea_dock(section_dock)) + btn.setProperty("_pyber_hide_wired", True) + btn.setStyleSheet( + "QToolButton {" + " background: transparent;" + " color: #f3f5f8;" + " border: none;" + " padding: 0px;" + " margin: 0px;" + " font-size: 8pt;" + " font-weight: 700;" + " }" + "QToolButton:hover {" + " background: transparent;" + " color: #ffffff;" + " border: none;" + " }" ) + except Exception: continue - self.lbl_beh.setText(f"{len(self._behavior_sources)} file(s) loaded") - def _load_processed_paths(self, paths: List[str], replace: bool) -> None: - loaded: List[ProcessedTrial] = [] - for p in paths: - ext = os.path.splitext(p)[1].lower() - if ext == ".csv": - trial = self._load_processed_csv(p) - elif ext in (".h5", ".hdf5"): - trial = self._load_processed_h5(p) - else: - trial = None - if trial is not None: - loaded.append(trial) - if not loaded: + def _hide_dockarea_dock(self, dock: Dock) -> None: + if dock is None: return - if replace: - self._processed = loaded + try: + dock.hide() + except Exception: + return + for key, candidate in self._dockarea_docks.items(): + if candidate is dock: + self._set_section_button_checked(key, False) + if self._last_opened_section == key: + self._last_opened_section = None + break + self._save_panel_layout_state() + + def _dockarea_active_key(self) -> Optional[str]: + active = self._last_opened_section if self._last_opened_section in self._dockarea_docks else None + if active is not None: + return active + for key in _FIXED_POST_RIGHT_TAB_ORDER: + dock = self._dockarea_dock(key) + if dock is not None and dock.isVisible(): + return key + for key, dock in self._dockarea_docks.items(): + if dock.isVisible(): + return key + return None + + def _set_dockarea_visible(self, key: str, visible: bool) -> None: + dock = self._dockarea_dock(key) + if dock is None: + return + if visible: + dock.show() else: - self._processed.extend(loaded) - self.lbl_group.setText(f"{len(self._processed)} file(s) loaded") - self._update_file_lists() - self._set_resample_from_processed() - self._compute_psth() + dock.hide() - def _on_preprocessed_files_dropped(self, paths: List[str]) -> None: - allowed = {".csv", ".h5", ".hdf5"} - keep = [p for p in paths if os.path.splitext(p)[1].lower() in allowed] - if not keep: + def _arrange_dockarea_default(self) -> None: + if self._dockarea is None: return - self._load_processed_paths(keep, replace=False) - - def _on_behavior_files_dropped(self, paths: List[str]) -> None: - allowed = {".csv", ".xlsx"} - keep = [p for p in paths if os.path.splitext(p)[1].lower() in allowed] - if not keep: + setup = self._dockarea_dock("setup") + psth = self._dockarea_dock("psth") + spatial = self._dockarea_dock("spatial") + signal = self._dockarea_dock("signal") + behavior = self._dockarea_dock("behavior") + export = self._dockarea_dock("export") + if setup is None: return - self._load_behavior_paths(keep, replace=False) - self._refresh_behavior_list() - - # ---- behavior files ---- + self._dockarea.addDock(setup, "right") + # Keep section panels in one tab stack to avoid layout churn/floating glitches. + if psth is not None: + self._dockarea.addDock(psth, "above", setup) + if spatial is not None: + self._dockarea.addDock(spatial, "above", setup) + if export is not None: + self._dockarea.addDock(export, "above", setup) + if signal is not None: + self._dockarea.addDock(signal, "above", setup) + if behavior is not None: + self._dockarea.addDock(behavior, "above", setup) + + def _dockarea_state_payload(self) -> Dict[str, object]: + if self._dockarea is None: + return {} + try: + return dict(self._dockarea.saveState() or {}) + except Exception: + return {} - def _update_align_ui(self) -> None: - use_dio = self.combo_align.currentText().startswith("DIO") - for w in (self.combo_dio, self.combo_dio_polarity, self.combo_dio_align): - w.setEnabled(use_dio) + def _dockarea_apply_visibility_map(self, visible_map: Dict[str, bool]) -> None: + for key, dock in self._dockarea_docks.items(): + vis = bool(visible_map.get(key, False)) + if vis: + dock.show() + else: + dock.hide() + self._sync_section_button_states_from_docks() - use_beh = not use_dio - self.btn_load_beh.setEnabled(use_beh) - self.combo_behavior_name.setEnabled(use_beh) - self.combo_behavior_align.setEnabled(use_beh) + def _save_dockarea_layout_state(self) -> None: + if self._dockarea is None: + return + state = self._dockarea_state_payload() + visible = {key: bool(dock.isVisible()) for key, dock in self._dockarea_docks.items()} + active = self._dockarea_active_key() or "" + try: + self._settings.setValue(_POST_DOCKAREA_STATE_KEY, json.dumps(state)) + self._settings.setValue(_POST_DOCKAREA_VISIBLE_KEY, json.dumps(visible)) + self._settings.setValue(_POST_DOCKAREA_ACTIVE_KEY, active) + self._settings.sync() + except Exception: + pass - is_transition = self.combo_behavior_align.currentText().startswith("Transition") and use_beh - for w in ( - self.combo_behavior_from, - self.combo_behavior_to, - self.spin_transition_gap, - self.lbl_trans_from, - self.lbl_trans_to, - self.lbl_trans_gap, - ): + def _restore_dockarea_layout_state(self) -> None: + if self._dockarea is None: + return + self._arrange_dockarea_default() + raw_state = self._settings.value(_POST_DOCKAREA_STATE_KEY, "") + try: + if isinstance(raw_state, str) and raw_state.strip(): + parsed = json.loads(raw_state) + if isinstance(parsed, dict): + self._dockarea.restoreState(parsed, missing="ignore", extra="bottom") + except Exception: + pass + visible_map: Dict[str, bool] = {} + raw_vis = self._settings.value(_POST_DOCKAREA_VISIBLE_KEY, "") + try: + if isinstance(raw_vis, str) and raw_vis.strip(): + parsed_vis = json.loads(raw_vis) + if isinstance(parsed_vis, dict): + visible_map = {str(k): bool(v) for k, v in parsed_vis.items()} + except Exception: + visible_map = {} + if not visible_map: + visible_map = self._dockarea_default_visibility_map() + self._dockarea_apply_visibility_map(visible_map) + active = str(self._settings.value(_POST_DOCKAREA_ACTIVE_KEY, "setup") or "setup") + dock = self._dockarea_dock(active) + if dock is not None and dock.isVisible(): + try: + dock.raiseDock() + except Exception: + pass + self._last_opened_section = active + return + self._last_opened_section = None + for key in _FIXED_POST_RIGHT_TAB_ORDER: + fallback = self._dockarea_dock(key) + if fallback is not None and fallback.isVisible(): + try: + fallback.raiseDock() + except Exception: + pass + self._last_opened_section = key + break + + def _apply_fixed_dockarea_layout(self) -> None: + if not self._use_pg_dockarea_layout: + return + if self._dockarea is None or not self._dockarea_docks: + return + self._apply_fixed_dock_features() + visible_map = {key: bool(dock.isVisible()) for key, dock in self._dockarea_docks.items()} + if not any(visible_map.values()): + visible_map = self._dockarea_default_visibility_map() + self._suspend_panel_layout_persistence = True + try: + self._arrange_dockarea_default() + for key in self._dockarea_docks.keys(): + should_show = bool(visible_map.get(key, False)) + self._set_dockarea_visible(key, should_show) + active = self._last_opened_section if bool(visible_map.get(self._last_opened_section or "", False)) else None + if active is None: + for key in _FIXED_POST_RIGHT_TAB_ORDER: + if bool(visible_map.get(key, False)): + active = key + break + if active is None: + for key in self._dockarea_docks.keys(): + if bool(visible_map.get(key, False)): + active = key + break + dock = self._dockarea_dock(active) if active else None + if dock is not None and dock.isVisible(): + try: + dock.raiseDock() + except Exception: + pass + self._last_opened_section = active + self._sync_section_button_states_from_docks() + self._dock_layout_restored = True + finally: + self._suspend_panel_layout_persistence = False + self._save_dockarea_layout_state() + + def _activate_dockarea_tab(self, key: str) -> None: + if key not in self._dockarea_docks: + return + self._set_dockarea_visible(key, True) + dock = self._dockarea_dock(key) + if dock is not None: + try: + dock.raiseDock() + except Exception: + pass + self._last_opened_section = key + + def _on_dockarea_dock_closed(self, key: str) -> None: + self._set_section_button_checked(key, False) + if self._last_opened_section == key: + self._last_opened_section = None + self._save_panel_layout_state() + + def _setup_section_popups(self) -> None: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + return + if self._section_popups: + return + host = self._dock_main_window() + if host is None: + # The tab can be created before it is attached to the main window. + # Retry from showEvent once host is available. + return + self._dock_host = host + section_map: Dict[str, Tuple[str, QtWidgets.QWidget]] = { + "setup": ("Setup", self.section_setup), + "psth": ("PSTH", self.section_psth), + "signal": ("Signal Event Analyzer", self.section_signal), + "behavior": ("Behavior Analysis", self.section_behavior), + "spatial": ("Spatial", self.section_spatial), + "export": ("Export", self.section_export), + } + for key, (title, widget) in section_map.items(): + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self._apply_scroll_theme(scroll) + # Keep section widgets shrinkable so dock stacks do not clip content. + widget.setMinimumSize(0, 0) + widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) + scroll.setWidget(widget) + self._section_scroll_hosts[key] = scroll + + dock = QtWidgets.QDockWidget(title, host) + dock.setObjectName(f"post.{key}.dock") + dock.setAllowedAreas(QtCore.Qt.DockWidgetArea.AllDockWidgetAreas) + dock.setMinimumWidth(320) + dock.setFeatures( + QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetClosable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetMovable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) + dock.setWidget(scroll) + dock.visibilityChanged.connect( + lambda visible, section_key=key: self._on_section_popup_visibility(section_key, visible) + ) + dock.topLevelChanged.connect(lambda *_: self._save_panel_layout_state()) + dock.dockLocationChanged.connect(lambda *_: self._save_panel_layout_state()) + host.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, dock) + # Match preprocessing behavior: popups open floating by default. + dock.setFloating(True) + dock.hide() + self._section_popups[key] = dock + + self._apply_fixed_dock_features() + + # If popups become available after delayed host attachment, restore once here. + if self._panel_layout_persistence_ready and not self._dock_layout_restored: + self._restore_panel_layout_state() + self._dock_layout_restored = True + # Main window can use this callback to retry pending dock restores. + try: + if hasattr(host, "on_post_docks_ready"): + host.on_post_docks_ready() + elif hasattr(host, "onPostDocksReady"): + host.onPostDocksReady() + except Exception: + pass + + def _set_section_button_checked(self, key: str, checked: bool) -> None: + btn = self._section_buttons.get(key) + if btn is None: + return + btn.blockSignals(True) + btn.setChecked(bool(checked)) + btn.blockSignals(False) + + def _apply_fixed_dock_features(self) -> None: + if self._use_pg_dockarea_layout: + for key, dock in self._dockarea_docks.items(): + if dock is None or not hasattr(dock, "label"): + continue + closable = not (self._force_fixed_default_layout and key in _FIXED_POST_RIGHT_SECTIONS) + try: + dock.label.setClosable(bool(closable)) + except Exception: + pass + return + if not self._section_popups: + return + for key, dock in self._section_popups.items(): + if dock is None: + continue + if self._force_fixed_default_layout: + if key in _FIXED_POST_RIGHT_SECTIONS: + features = QtWidgets.QDockWidget.DockWidgetFeature.NoDockWidgetFeatures + else: + features = ( + QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetClosable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + allowed = ( + QtCore.Qt.DockWidgetArea.BottomDockWidgetArea + if key == "export" + else QtCore.Qt.DockWidgetArea.RightDockWidgetArea + ) + else: + features = ( + QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetClosable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetMovable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) + allowed = QtCore.Qt.DockWidgetArea.AllDockWidgetAreas + try: + dock.setAllowedAreas(allowed) + dock.setFeatures(features) + except Exception: + pass + + def _toggle_section_popup(self, key: str, checked: bool) -> None: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + dock = self._dockarea_dock(key) + if dock is None: + return + if checked: + self._activate_dockarea_tab(key) + else: + self._set_dockarea_visible(key, False) + self._sync_section_button_states_from_docks() + self._save_panel_layout_state() + return + if not self._section_popups: + self._setup_section_popups() + dock = self._section_popups.get(key) + if dock is None: + return + host = self._dock_host or self._dock_main_window() + tabs = getattr(host, "tabs", None) if host is not None else None + post_active = not isinstance(tabs, QtWidgets.QTabWidget) or tabs.currentWidget() is self + fixed_required = ( + self._force_fixed_default_layout + and key in _FIXED_POST_RIGHT_SECTIONS + and post_active + and not self._post_docks_hidden_for_tab_switch + ) + if fixed_required: + self._activate_fixed_right_tab(key) + self._save_panel_layout_state() + return + if checked: + # Keep user-selected docking mode. Only reposition if currently floating off-screen. + if dock.isFloating(): + if key not in self._section_popup_initialized or not self._is_popup_on_screen(dock): + self._position_section_popup(dock, key) + self._section_popup_initialized.add(key) + dock.show() + dock.raise_() + dock.activateWindow() + self._last_opened_section = key + else: + dock.hide() + self._save_panel_layout_state() + + def _on_section_popup_visibility(self, key: str, visible: bool) -> None: + if self._use_pg_dockarea_layout: + self._set_section_button_checked(key, visible) + if visible: + self._last_opened_section = key + elif self._last_opened_section == key: + self._last_opened_section = None + self._save_panel_layout_state() + return + host = self._dock_host or self._dock_main_window() + tabs = getattr(host, "tabs", None) if host is not None else None + post_active = not isinstance(tabs, QtWidgets.QTabWidget) or tabs.currentWidget() is self + fixed_required = ( + self._force_fixed_default_layout + and key in _FIXED_POST_RIGHT_SECTIONS + and post_active + and not self._post_docks_hidden_for_tab_switch + ) + if fixed_required and not visible: + # Spatial/PSTH are represented as tabs inside Setup dock in fixed mode. + if key == "setup": + self._activate_fixed_right_tab("setup") + else: + self._set_section_button_checked(key, True) + self._save_panel_layout_state() + return + self._set_section_button_checked(key, visible) + if visible: + self._last_opened_section = key + elif self._last_opened_section == key: + self._last_opened_section = None + self._save_panel_layout_state() + + def _hide_all_section_popups(self) -> None: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + for key in self._dockarea_docks.keys(): + self._set_dockarea_visible(key, False) + self._set_section_button_checked(key, False) + self._last_opened_section = None + self._save_panel_layout_state() + return + host = self._dock_host or self._dock_main_window() + if self._force_fixed_default_layout: + if host is not None: + self._apply_fixed_right_tabs_as_single_dock(host, active_key="setup") + for key in ("signal", "behavior", "export"): + dock = self._section_popups.get(key) + if dock is None: + continue + dock.hide() + self._set_section_button_checked(key, False) + self._last_opened_section = "setup" + self._save_panel_layout_state() + return + for key, dock in self._section_popups.items(): + keep_visible = self._force_fixed_default_layout and key in _FIXED_POST_RIGHT_SECTIONS + if keep_visible: + dock.blockSignals(True) + try: + if host is not None: + host.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, dock) + dock.setFloating(False) + dock.show() + dock.raise_() + finally: + dock.blockSignals(False) + self._set_section_button_checked(key, True) + else: + dock.hide() + self._set_section_button_checked(key, False) + if self._force_fixed_default_layout: + self._last_opened_section = "setup" + else: + self._last_opened_section = None + self._save_panel_layout_state() + + def _default_popup_size(self, key: str) -> Tuple[int, int]: + size_map = { + "setup": (420, 620), + "psth": (420, 640), + "signal": (420, 640), + "behavior": (500, 620), + "spatial": (420, 520), + "export": (340, 300), + } + return size_map.get(key, (420, 620)) + + def _active_screen_geometry(self) -> QtCore.QRect: + handle = self.windowHandle() + screen = handle.screen() if handle else None + if screen is None: + screen = QtGui.QGuiApplication.screenAt(self.mapToGlobal(self.rect().center())) + if screen is None: + screen = QtGui.QGuiApplication.primaryScreen() + if screen is None: + return QtCore.QRect(0, 0, 1920, 1080) + return screen.availableGeometry() + + def _position_section_popup(self, dock: QtWidgets.QDockWidget, key: str) -> None: + panel_global = self.mapToGlobal(self.rect().topLeft()) + panel_rect = QtCore.QRect(panel_global, self.size()) + screen_rect = self._active_screen_geometry() + pref_w, pref_h = self._default_popup_size(key) + w = min(pref_w, max(320, screen_rect.width() - 40)) + h = min(pref_h, max(240, screen_rect.height() - 40)) + + x_right = panel_rect.x() + panel_rect.width() + 12 + x_left = panel_rect.x() - w - 12 + y_pref = panel_rect.y() + 60 + + x_min = screen_rect.x() + 8 + x_max = screen_rect.x() + max(8, screen_rect.width() - w - 8) + y_min = screen_rect.y() + 8 + y_max = screen_rect.y() + max(8, screen_rect.height() - h - 8) + + if x_right <= x_max: + x = x_right + elif x_left >= x_min: + x = x_left + else: + x = x_max + y = min(max(y_pref, y_min), y_max) + + dock.resize(int(w), int(h)) + dock.move(int(x), int(y)) + + def _is_popup_on_screen(self, dock: QtWidgets.QDockWidget) -> bool: + rect = dock.frameGeometry() + if rect.width() <= 0 or rect.height() <= 0: + return False + for screen in QtGui.QGuiApplication.screens(): + if screen.availableGeometry().intersects(rect): + return True + return False + + def _dock_area_from_settings( + self, + value: object, + default: QtCore.Qt.DockWidgetArea = QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + ) -> QtCore.Qt.DockWidgetArea: + left_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, 1) + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + top_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.TopDockWidgetArea, 4) + bottom_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, 8) + area_int = _dock_area_to_int(value, _dock_area_to_int(default, right_i)) + area_map: Dict[int, QtCore.Qt.DockWidgetArea] = { + left_i: QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, + right_i: QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + top_i: QtCore.Qt.DockWidgetArea.TopDockWidgetArea, + bottom_i: QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, + } + if area_int in area_map: + return area_map[area_int] + return default + + def _to_qbytearray(self, value: object) -> Optional[QtCore.QByteArray]: + if isinstance(value, QtCore.QByteArray): + return value + if isinstance(value, (bytes, bytearray)): + return QtCore.QByteArray(bytes(value)) + if isinstance(value, str): + try: + return QtCore.QByteArray.fromBase64(value.encode("utf-8")) + except Exception: + return None + return None + + def _sync_section_button_states_from_docks(self) -> None: + if self._use_pg_dockarea_layout: + self._last_opened_section = None + for key, dock in self._dockarea_docks.items(): + visible = bool(dock.isVisible()) + self._set_section_button_checked(key, visible) + if visible and self._last_opened_section is None: + self._last_opened_section = key + return + self._last_opened_section = None + for key, dock in self._section_popups.items(): + visible = bool(dock.isVisible()) + if self._force_fixed_default_layout and key in _FIXED_POST_RIGHT_SECTIONS: + # In fixed mode these sections are always present as right-side tabs. + visible = True + self._set_section_button_checked(key, visible) + if visible: + self._last_opened_section = key + + def _has_saved_layout_state(self) -> bool: + if self._use_pg_dockarea_layout: + try: + return bool(self._settings.contains(_POST_DOCKAREA_STATE_KEY) or self._settings.contains(_POST_DOCKAREA_VISIBLE_KEY)) + except Exception: + return False + try: + if self._settings.contains(_POST_DOCK_STATE_KEY): + return True + keys = list(self._section_popups.keys()) or ["setup", "psth", "signal", "behavior", "spatial", "export"] + for key in keys: + if self._settings.contains(f"post_section_docks/{key}/visible"): + return True + except Exception: + pass + return False + + def _save_panel_layout_state(self) -> None: + if not self._panel_layout_persistence_ready: + return + if self._use_pg_dockarea_layout: + if self._is_restoring_panel_layout or self._suspend_panel_layout_persistence: + return + self._save_dockarea_layout_state() + return + if self._force_fixed_default_layout: + self._persist_fixed_post_default_state() + return + if self._is_restoring_panel_layout: + return + if self._suspend_panel_layout_persistence: + return + # Do not overwrite stored layout while post panels are hidden for tab switching. + if self._post_docks_hidden_for_tab_switch: + return + host = self._dock_host or self._dock_main_window() + if host is None: + return + self._dock_host = host + if not self._section_popups: + return + for key, dock in self._section_popups.items(): + try: + base = f"post_section_docks/{key}" + # Preserve pre-hide state while switching tabs so settings are not overwritten + # with temporary hidden values. + cached = self._post_section_state_before_hide.get(key, {}) if self._post_docks_hidden_for_tab_switch else {} + visible = bool(cached.get("visible", dock.isVisible())) + floating = bool(cached.get("floating", dock.isFloating())) + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + area_val = _dock_area_to_int(cached.get("area", host.dockWidgetArea(dock)), right_i) + geom = cached.get("geometry", dock.saveGeometry()) + self._settings.setValue(f"{base}/visible", visible) + self._settings.setValue(f"{base}/floating", floating) + self._settings.setValue(f"{base}/area", area_val) + self._settings.setValue(f"{base}/geometry", geom) + except Exception: + continue + try: + self._settings.sync() + except Exception: + pass + + def _persist_hidden_layout_state_from_cache(self) -> None: + """Persist cached post layout captured when tab-switch hiding post docks.""" + if self._use_pg_dockarea_layout: + return + if self._force_fixed_default_layout: + self._persist_fixed_post_default_state() + return + if not self._post_docks_hidden_for_tab_switch: + return + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + if not self._section_popups: + return + for key in self._section_popups.keys(): + try: + state = self._post_section_state_before_hide.get(key, {}) + base = f"post_section_docks/{key}" + self._settings.setValue(f"{base}/visible", bool(state.get("visible", False))) + self._settings.setValue(f"{base}/floating", bool(state.get("floating", True))) + self._settings.setValue(f"{base}/area", _dock_area_to_int(state.get("area", right_i), right_i)) + geom = state.get("geometry") + if isinstance(geom, QtCore.QByteArray) and not geom.isEmpty(): + self._settings.setValue(f"{base}/geometry", geom) + except Exception: + continue + try: + self._settings.sync() + except Exception: + pass + + def flush_post_section_state_to_settings(self) -> None: + """Flush latest post section visibility/layout into QSettings immediately.""" + if self._use_pg_dockarea_layout: + self._save_panel_layout_state() + return + if self._post_docks_hidden_for_tab_switch: + self._persist_hidden_layout_state_from_cache() + return + self._save_panel_layout_state() + + def persist_layout_state_snapshot(self) -> None: + """ + Persist post dock state safely. + Uses cached tab-switch state while hidden, otherwise captures current host topology. + """ + if self._use_pg_dockarea_layout: + self._save_panel_layout_state() + return + if self._force_fixed_default_layout: + self._persist_fixed_post_default_state() + return + if self._post_docks_hidden_for_tab_switch: + self._persist_hidden_layout_state_from_cache() + return + + host = self._dock_host or self._dock_main_window() + if host is not None: + self._dock_host = host + try: + state = None + if hasattr(host, "captureDockSnapshotForTab"): + state = host.captureDockSnapshotForTab("post") + elif hasattr(host, "saveState"): + state = host.saveState(_DOCK_STATE_VERSION) + if state is not None and not state.isEmpty(): + self._settings.setValue(_POST_DOCK_STATE_KEY, state) + except Exception: + pass + self.flush_post_section_state_to_settings() + + def _restore_panel_layout_state(self) -> None: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + if self._force_fixed_default_layout: + self._apply_fixed_dockarea_layout() + else: + self._restore_dockarea_layout_state() + return + if self._force_fixed_default_layout: + self.apply_fixed_default_layout() + return + self._is_restoring_panel_layout = True + try: + if not self._section_popups: + self._setup_section_popups() + host = self._dock_host or self._dock_main_window() + if host is None or not self._section_popups: + return + self._dock_host = host + has_saved_layout = any( + bool(self._settings.contains(f"post_section_docks/{key}/visible")) + for key in self._section_popups.keys() + ) + for key, dock in self._section_popups.items(): + base = f"post_section_docks/{key}" + default_visible = (key == "setup") if not has_saved_layout else False + visible = _to_bool(self._settings.value(f"{base}/visible", default_visible), bool(default_visible)) + floating = _to_bool(self._settings.value(f"{base}/floating", True), True) + area_val = self._settings.value( + f"{base}/area", + _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2), + ) + area = self._dock_area_from_settings(area_val, QtCore.Qt.DockWidgetArea.RightDockWidgetArea) + geom = self._to_qbytearray(self._settings.value(f"{base}/geometry", None)) + + dock.blockSignals(True) + try: + if floating: + dock.setFloating(True) + else: + host.addDockWidget(area, dock) + dock.setFloating(False) + + # Apply geometry for both floating and docked states. + if geom is not None and not geom.isEmpty(): + dock.restoreGeometry(geom) + self._section_popup_initialized.add(key) + + if visible: + if dock.isFloating() and not self._is_popup_on_screen(dock): + self._position_section_popup(dock, key) + dock.show() + else: + dock.hide() + finally: + dock.blockSignals(False) + except Exception: + pass + finally: + self._sync_section_button_states_from_docks() + self._is_restoring_panel_layout = False + + # ---- bridge reception ---- + + def set_current_source_label(self, filename: str, channel: str) -> None: + self.lbl_current.setText(f"Current: {filename} [{channel}]") + if hasattr(self, "lbl_plot_file"): + self.lbl_plot_file.setText(f"File: {filename}") + self._update_status_strip() + + def notify_preprocessing_updated(self, _processed: ProcessedTrial) -> None: + # no-op; user presses compute or update + pass + + def _set_resample_from_processed(self) -> None: + if not self._processed: + return + proc = self._processed[0] + fs = float(proc.fs_used) if np.isfinite(proc.fs_used) else float(proc.fs_actual) + if not np.isfinite(fs) or fs <= 0: + t = np.asarray(proc.time, float) if proc.time is not None else np.array([], float) + if t.size > 2: + dt = np.nanmedian(np.diff(t)) + fs = 1.0 / float(dt) if np.isfinite(dt) and dt > 0 else np.nan + if np.isfinite(fs) and fs > 0: + fs = max(1.0, min(1000.0, float(fs))) + self.spin_resample.setValue(fs) + self._update_status_strip() + + @QtCore.Slot(list) + def receive_current_processed(self, processed_list: List[ProcessedTrial]) -> None: + self._processed = processed_list or [] + if not self._autosave_restoring: + self._project_dirty = True + # update trace preview with first entry + self._refresh_behavior_list() + self._set_resample_from_processed() + self._update_trace_preview() + self._compute_spatial_heatmap() + self._update_data_availability() + self._update_status_strip() + if self._pending_project_recompute_from_current: + self._pending_project_recompute_from_current = False + self._compute_psth() + self._compute_spatial_heatmap() + + def append_processed(self, processed_list: List[ProcessedTrial]) -> None: + if not processed_list: + return + self._processed.extend(processed_list) + if not self._autosave_restoring: + self._project_dirty = True + self._refresh_behavior_list() + self._set_resample_from_processed() + self._update_trace_preview() + self._compute_spatial_heatmap() + self._update_data_availability() + self._update_status_strip() + + @QtCore.Slot(list) + def receive_dio_list(self, dio_list: List[str]) -> None: + self.combo_dio.clear() + for d in dio_list or []: + self.combo_dio.addItem(d) + self._update_status_strip() + + @QtCore.Slot(str, str, object, object) + def receive_dio_data(self, path: str, dio_name: str, t: Optional[np.ndarray], x: Optional[np.ndarray]) -> None: + if t is None or x is None: + return + self._dio_cache[(path, dio_name)] = (np.asarray(t, float), np.asarray(x, float)) + + def _load_recent_paths(self, key: str) -> List[str]: + raw = self._settings.value(key, "[]", type=str) + try: + data = json.loads(raw) if raw else [] + except Exception: + data = [] + out: List[str] = [] + if isinstance(data, list): + for item in data: + p = str(item or "").strip() + if p: + out.append(p) + return out + + def _save_recent_paths(self, key: str, paths: List[str]) -> None: + try: + self._settings.setValue(key, json.dumps(paths)) + except Exception: + pass + + def _push_recent_paths(self, key: str, paths: List[str], max_items: int = 15) -> None: + if not paths: + return + existing = self._load_recent_paths(key) + merged: List[str] = [] + for p in paths: + sp = str(p or "").strip() + if not sp: + continue + if sp in merged: + continue + merged.append(sp) + for p in existing: + if p not in merged: + merged.append(p) + self._save_recent_paths(key, merged[:max_items]) + + def _prune_recent_paths(self, key: str) -> None: + recent = self._load_recent_paths(key) + kept = [p for p in recent if os.path.isfile(p)] + self._save_recent_paths(key, kept) + + def _refresh_recent_postprocessing_menus(self) -> None: + self._refresh_recent_menu( + self.menu_recent_processed, + key="postprocess_recent_processed_paths", + loader=self._load_recent_processed_path, + ) + self._refresh_recent_menu( + self.menu_recent_behavior, + key="postprocess_recent_behavior_paths", + loader=self._load_recent_behavior_path, + ) + self._refresh_recent_menu( + self.menu_recent_projects, + key="postprocess_recent_project_paths", + loader=self._load_recent_project_path, + ) + + def _refresh_recent_menu( + self, + menu: QtWidgets.QMenu, + key: str, + loader: Callable[[str], None], + ) -> None: + if menu is None: + return + menu.clear() + recent = self._load_recent_paths(key) + if not recent: + act_empty = menu.addAction("(No recent files)") + act_empty.setEnabled(False) + return + + missing: List[str] = [] + for path in recent: + label = os.path.basename(path) or path + if not os.path.isfile(path): + label = f"{label} (missing)" + act = menu.addAction(label) + act.setToolTip(path) + act.setEnabled(os.path.isfile(path)) + if os.path.isfile(path): + act.triggered.connect(lambda _checked=False, p=path: loader(p)) + else: + missing.append(path) + menu.addSeparator() + act_clear = menu.addAction("Clear recent") + act_clear.triggered.connect(lambda: self._save_recent_paths(key, [])) + if missing: + act_prune = menu.addAction("Remove missing") + act_prune.triggered.connect(lambda: self._prune_recent_paths(key)) + + def _load_recent_processed_path(self, path: str) -> None: + if not path or not os.path.isfile(path): + QtWidgets.QMessageBox.warning(self, "Load recent", "Selected recent processed file is missing.") + return + self._load_processed_paths([path], replace=True) + try: + self._settings.setValue("postprocess_last_dir", os.path.dirname(path)) + except Exception: + pass + + def _load_recent_behavior_path(self, path: str) -> None: + if not path or not os.path.isfile(path): + QtWidgets.QMessageBox.warning(self, "Load recent", "Selected recent behavior file is missing.") + return + self._load_behavior_paths([path], replace=True) + self._refresh_behavior_list() + try: + self._settings.setValue("postprocess_last_dir", os.path.dirname(path)) + except Exception: + pass + + def _load_recent_project_path(self, path: str) -> None: + if not path or not os.path.isfile(path): + QtWidgets.QMessageBox.warning(self, "Load recent", "Selected recent project file is missing.") + return + self._load_project_from_path(path) + + def _load_behavior_paths(self, paths: List[str], replace: bool) -> None: + if replace: + self._behavior_sources.clear() + parse_mode = self._current_behavior_parse_mode() + fps = float(self.spin_behavior_fps.value()) if hasattr(self, "spin_behavior_fps") else 0.0 + loaded_any = False + for p in paths: + stem = os.path.splitext(os.path.basename(p))[0] + ext = os.path.splitext(p)[1].lower() + try: + if ext == ".csv": + info = _load_behavior_csv(p, parse_mode=parse_mode, fps=fps) + elif ext == ".xlsx": + import pandas as pd + xls = pd.ExcelFile(p, engine="openpyxl") + sheet = None + if len(xls.sheet_names) > 1: + sheet, ok = QtWidgets.QInputDialog.getItem( + self, + "Select sheet", + f"{os.path.basename(p)}: choose sheet", + xls.sheet_names, + 0, + False, + ) + if not ok: + continue + info = _load_behavior_ethovision(p, sheet_name=sheet, parse_mode=parse_mode, fps=fps) + else: + continue + has_behaviors = bool(info.get("behaviors") or {}) + has_trajectory = bool(info.get("trajectory") or {}) + if not has_behaviors and not has_trajectory: + QtWidgets.QMessageBox.warning( + self, + "Behavior load warning", + f"No behavior or trajectory numeric columns detected in {os.path.basename(p)} for the selected file type.", + ) + info["source_path"] = str(p) + self._behavior_sources[stem] = info + loaded_any = True + except Exception as exc: + QtWidgets.QMessageBox.warning( + self, + "Behavior load failed", + f"Could not load {os.path.basename(p)}:\n{exc}", + ) + continue + mode_label = "timestamps" if parse_mode == _BEHAVIOR_PARSE_TIMESTAMPS else "binary" + self.lbl_beh.setText(f"{len(self._behavior_sources)} file(s) loaded [{mode_label}]") + self._update_behavior_time_panel() + self._push_recent_paths("postprocess_recent_behavior_paths", paths) + if loaded_any and not self._autosave_restoring: + self._project_dirty = True + self._update_data_availability() + self._update_status_strip() + + def _load_processed_paths(self, paths: List[str], replace: bool) -> None: + loaded: List[ProcessedTrial] = [] + for p in paths: + ext = os.path.splitext(p)[1].lower() + if ext == ".csv": + trial = self._load_processed_csv(p) + elif ext in (".h5", ".hdf5"): + trial = self._load_processed_h5(p) + else: + trial = None + if trial is not None: + loaded.append(trial) + if not loaded: + return + if replace: + self._processed = loaded + else: + self._processed.extend(loaded) + if not self._autosave_restoring: + self._project_dirty = True + self.lbl_group.setText(f"{len(self._processed)} file(s) loaded") + self._push_recent_paths("postprocess_recent_processed_paths", paths) + self._update_file_lists() + self._set_resample_from_processed() + self._compute_psth() + self._compute_spatial_heatmap() + self._update_data_availability() + self._update_status_strip() + + def _on_preprocessed_files_dropped(self, paths: List[str]) -> None: + allowed = {".csv", ".h5", ".hdf5"} + keep = [p for p in paths if os.path.splitext(p)[1].lower() in allowed] + if not keep: + return + self._load_processed_paths(keep, replace=False) + + def _on_behavior_files_dropped(self, paths: List[str]) -> None: + allowed = {".csv", ".xlsx"} + keep = [p for p in paths if os.path.splitext(p)[1].lower() in allowed] + if not keep: + return + self._load_behavior_paths(keep, replace=False) + self._refresh_behavior_list() + + # ---- behavior files ---- + + def _update_align_ui(self) -> None: + use_dio = _is_doric_channel_align(self.combo_align.currentText()) + for w in ( + self.lbl_dio_channel, + self.combo_dio, + self.lbl_dio_polarity, + self.combo_dio_polarity, + self.lbl_dio_align, + self.combo_dio_align, + ): + w.setEnabled(use_dio) + w.setVisible(use_dio) + + use_beh = not use_dio + self.lbl_behavior_file_type.setEnabled(use_beh) + self.lbl_behavior_file_type.setVisible(use_beh) + self.combo_behavior_file_type.setEnabled(use_beh) + self.combo_behavior_file_type.setVisible(use_beh) + self.btn_load_beh.setEnabled(use_beh) + self.btn_load_beh.setVisible(use_beh) + self.combo_behavior_name.setEnabled(use_beh) + self.combo_behavior_name.setVisible(use_beh) + show_time_panel = bool(use_beh and self._behavior_sources_need_generated_time()) + self.grp_behavior_time.setEnabled(show_time_panel) + self.grp_behavior_time.setVisible(show_time_panel) + + def _behavior_sources_need_generated_time(self) -> bool: + for info in self._behavior_sources.values(): + if bool(info.get("needs_generated_time", False)): + return True + return False + + def _update_behavior_time_panel(self) -> None: + need_time = self._behavior_sources_need_generated_time() + count = sum(1 for info in self._behavior_sources.values() if bool(info.get("needs_generated_time", False))) + if need_time: + self.lbl_behavior_time_hint.setText( + f"No time column detected in {count} behavior file(s). Generate time from FPS." + ) + else: + self.lbl_behavior_time_hint.setText("No missing time columns detected.") + self._update_align_ui() + + def _apply_behavior_time_settings(self) -> None: + fps = float(self.spin_behavior_fps.value()) if hasattr(self, "spin_behavior_fps") else 0.0 + updated = False + for info in self._behavior_sources.values(): + if not bool(info.get("needs_generated_time", False)): + continue + row_count = int(info.get("row_count", 0) or 0) + t = _generated_time_array(row_count, fps) + info["time"] = t.copy() + traj_t = np.asarray(info.get("trajectory_time", np.array([], float)), float) + if traj_t.size == 0: + info["trajectory_time"] = t.copy() + updated = True + if not updated: + return + if not self._autosave_restoring: + self._project_dirty = True + self._refresh_behavior_list() + self._compute_psth() + self._compute_spatial_heatmap() + self._update_behavior_time_panel() + self._update_status_strip() + self.combo_behavior_align.setEnabled(use_beh) + self.combo_behavior_align.setVisible(use_beh) + + is_transition = self.combo_behavior_align.currentText().startswith("Transition") and use_beh + for w in ( + self.combo_behavior_from, + self.combo_behavior_to, + self.spin_transition_gap, + self.lbl_trans_from, + self.lbl_trans_to, + self.lbl_trans_gap, + ): w.setVisible(is_transition) if use_beh: @@ -965,12 +3149,172 @@ def _update_align_ui(self) -> None: self.combo_behavior_to.setEnabled(is_transition) self.spin_transition_gap.setEnabled(is_transition) self._update_trace_preview() + self._update_status_strip() + + def _current_behavior_parse_mode(self) -> str: + text = self.combo_behavior_file_type.currentText().strip().lower() if hasattr(self, "combo_behavior_file_type") else "" + if "timestamp" in text: + return _BEHAVIOR_PARSE_TIMESTAMPS + return _BEHAVIOR_PARSE_BINARY + + def _apply_view_layout(self) -> None: + layout = self.combo_view_layout.currentText() if hasattr(self, "combo_view_layout") else "Standard" + show_trace = True + show_heat = True + show_avg = True + show_signal = False + show_behavior = False + + self.plot_dur.setVisible(True) + self.plot_metrics.setVisible(True) + self.plot_global.setVisible(self.cb_global_metrics.isChecked()) + + if layout == "Heatmap focus": + show_signal = False + show_behavior = False + self.plot_dur.setVisible(False) + self.plot_metrics.setVisible(False) + self.plot_global.setVisible(False) + elif layout == "Trace focus": + show_heat = False + show_signal = False + show_behavior = False + self.plot_metrics.setVisible(False) + self.plot_global.setVisible(False) + elif layout == "Metrics focus": + show_heat = False + show_signal = False + show_behavior = False + self.plot_metrics.setVisible(True) + self.plot_global.setVisible(True) + elif layout == "All": + show_signal = True + show_behavior = True + + self.plot_trace.setVisible(show_trace) + self.row_heat.setVisible(show_heat) + self.row_avg.setVisible(show_avg) + self.row_signal.setVisible(show_signal) + self.row_behavior.setVisible(show_behavior) + + def _refresh_signal_file_combo(self) -> None: + if not hasattr(self, "combo_signal_file"): + return + prev = self.combo_signal_file.currentText().strip() + self.combo_signal_file.blockSignals(True) + self.combo_signal_file.clear() + for proc in self._processed: + stem = os.path.splitext(os.path.basename(proc.path))[0] if proc.path else "import" + self.combo_signal_file.addItem(stem) + if prev: + idx = self.combo_signal_file.findText(prev) + if idx >= 0: + self.combo_signal_file.setCurrentIndex(idx) + self.combo_signal_file.blockSignals(False) + has_multi = self.combo_signal_file.count() > 1 + self.combo_signal_scope.setEnabled(has_multi) + self.combo_signal_file.setEnabled(self.combo_signal_scope.currentText() == "Per file") + + def _update_data_availability(self) -> None: + has_processed = bool(self._processed) + has_behavior = bool(self._behavior_sources) + spatial_ready = has_processed and has_behavior + for w in ( + self.btn_compute, + self.btn_update, + self.btn_export, + self.btn_export_img, + self.btn_detect_peaks, + self.btn_export_peaks, + ): + w.setEnabled(has_processed) + for w in ( + self.combo_signal_source, + self.combo_signal_scope, + self.combo_signal_file, + self.combo_signal_method, + self.spin_peak_prominence, + self.spin_peak_height, + self.spin_peak_distance, + self.spin_peak_smooth, + self.combo_peak_baseline, + self.spin_peak_baseline_window, + self.spin_peak_rate_bin, + self.spin_peak_auc_window, + self.cb_peak_overlay, + ): + w.setEnabled(has_processed) + for w in ( + self.btn_compute_behavior, + self.btn_export_behavior_metrics, + self.btn_export_behavior_events, + self.combo_behavior_analysis, + self.spin_behavior_bin, + self.cb_behavior_aligned, + ): + w.setEnabled(has_behavior) + for w in ( + self.combo_spatial_x, + self.combo_spatial_y, + self.spin_spatial_bins_x, + self.spin_spatial_bins_y, + self.combo_spatial_weight, + self.cb_spatial_clip, + self.spin_spatial_clip_low, + self.spin_spatial_clip_high, + self.cb_spatial_time_filter, + self.spin_spatial_time_min, + self.spin_spatial_time_max, + self.spin_spatial_smooth, + self.combo_spatial_activity_mode, + self.cb_spatial_log, + self.cb_spatial_invert_y, + self.btn_compute_spatial, + self.btn_export_spatial_img, + ): + w.setEnabled(spatial_ready) + self.btn_spatial_help.setEnabled(True) + self._update_spatial_clip_enabled() + self._update_spatial_time_filter_enabled() + self._refresh_signal_file_combo() + + def _update_status_strip(self) -> None: + if not hasattr(self, "lbl_status"): + return + n_files = len(self._processed) + src_mode = "Group" if self.tab_sources.currentIndex() == 1 else "Single" + if self._processed: + proc0 = self._processed[0] + file_txt = os.path.basename(proc0.path) if proc0.path else "import" + self.lbl_plot_file.setText(f"File: {file_txt}") + fs_actual = float(proc0.fs_actual) if np.isfinite(proc0.fs_actual) else np.nan + fs_used = float(proc0.fs_used) if np.isfinite(proc0.fs_used) else np.nan + fs_txt = f"{fs_actual:.3g}->{fs_used:.3g}" if np.isfinite(fs_actual) and np.isfinite(fs_used) else "-" + output_label = proc0.output_label or "output" + else: + self.lbl_plot_file.setText("File: (none)") + fs_txt = "-" + output_label = "-" + align_src = self.combo_align.currentText() + if _is_doric_channel_align(align_src): + align_detail = self.combo_dio.currentText().strip() or "(none)" + align_mode = self.combo_dio_align.currentText() + else: + align_detail = self.combo_behavior_name.currentText().strip() or "(none)" + align_mode = self.combo_behavior_align.currentText() + ev_count = int(self._last_events.size) if isinstance(self._last_events, np.ndarray) else 0 + win_txt = f"{float(self.spin_pre.value()):.3g}/{float(self.spin_post.value()):.3g}s" + rs_txt = f"{float(self.spin_resample.value()):.3g}Hz" + status_msg = ( + f"Source: {src_mode} ({n_files}) | Output: {output_label} | Align: {align_src} [{align_detail}] {align_mode} | Events: {ev_count} | Window: {win_txt} | Resample: {rs_txt} | Fs: {fs_txt}" + ) + self.statusUpdate.emit(status_msg, 30000) def _update_event_filter_enabled(self) -> None: enabled = self.cb_filter_events.isChecked() - for w in (self.spin_event_start, self.spin_event_end, self.spin_dur_min, self.spin_dur_max): + for w in (self.spin_event_start, self.spin_event_end, self.spin_group_window, self.spin_dur_min, self.spin_dur_max): w.setEnabled(enabled) - self._save_settings() + self._queue_settings_save() def _update_metrics_enabled(self) -> None: enabled = self.cb_metrics.isChecked() @@ -983,7 +3327,7 @@ def _update_metrics_enabled(self) -> None: ): w.setEnabled(enabled) self._update_metric_regions() - self._save_settings() + self._queue_settings_save() def _update_global_metrics_enabled(self) -> None: enabled = self.cb_global_metrics.isChecked() @@ -996,21 +3340,129 @@ def _update_global_metrics_enabled(self) -> None: ): w.setEnabled(enabled) self._render_global_metrics() - self._save_settings() + self._apply_view_layout() + self._queue_settings_save() + + def _queue_settings_save(self, *_args: object) -> None: + if self._is_restoring_settings: + return + if not self._autosave_restoring: + self._project_dirty = True + timer = getattr(self, "_settings_save_timer", None) + if timer is None: + self._save_settings() + return + timer.start() + + def _wire_settings_autosave(self) -> None: + for combo in ( + self.combo_align, + self.combo_dio, + self.combo_dio_polarity, + self.combo_dio_align, + self.combo_behavior_file_type, + self.combo_behavior_name, + self.combo_behavior_align, + self.combo_behavior_from, + self.combo_behavior_to, + self.combo_metric, + self.combo_signal_source, + self.combo_signal_scope, + self.combo_signal_file, + self.combo_signal_method, + self.combo_peak_baseline, + self.combo_behavior_analysis, + self.combo_spatial_x, + self.combo_spatial_y, + self.combo_spatial_weight, + self.combo_spatial_activity_mode, + ): + combo.currentIndexChanged.connect(self._queue_settings_save) + + for spin in ( + self.spin_transition_gap, + self.spin_pre, + self.spin_post, + self.spin_b0, + self.spin_b1, + self.spin_resample, + self.spin_smooth, + self.spin_event_start, + self.spin_event_end, + self.spin_group_window, + self.spin_dur_min, + self.spin_dur_max, + self.spin_metric_pre0, + self.spin_metric_pre1, + self.spin_metric_post0, + self.spin_metric_post1, + self.spin_global_start, + self.spin_global_end, + self.spin_peak_prominence, + self.spin_peak_height, + self.spin_peak_distance, + self.spin_peak_smooth, + self.spin_peak_baseline_window, + self.spin_peak_rate_bin, + self.spin_peak_auc_window, + self.spin_behavior_bin, + self.spin_spatial_bins_x, + self.spin_spatial_bins_y, + self.spin_spatial_clip_low, + self.spin_spatial_clip_high, + self.spin_spatial_time_min, + self.spin_spatial_time_max, + self.spin_spatial_smooth, + ): + spin.valueChanged.connect(self._queue_settings_save) + + for chk in ( + self.cb_filter_events, + self.cb_metrics, + self.cb_global_metrics, + self.cb_global_amp, + self.cb_global_freq, + self.cb_peak_overlay, + self.cb_behavior_aligned, + self.cb_spatial_clip, + self.cb_spatial_time_filter, + self.cb_spatial_log, + self.cb_spatial_invert_y, + ): + chk.toggled.connect(self._queue_settings_save) + + def _refresh_section_scroll(self, key: str) -> None: + scroll = self._section_scroll_hosts.get(key) + if scroll is None: + return + try: + content = scroll.widget() + if content is not None: + if content.layout() is not None: + content.layout().activate() + content.updateGeometry() + scroll.updateGeometry() + scroll.viewport().update() + scroll.update() + except Exception: + pass def _toggle_filter_panel(self, hide: bool) -> None: self.btn_hide_filters.setText("Show" if hide else "Hide") for w in ( self.lbl_event_start, self.lbl_event_end, + self.lbl_group_window, self.lbl_dur_min, self.lbl_dur_max, self.spin_event_start, self.spin_event_end, + self.spin_group_window, self.spin_dur_min, self.spin_dur_max, ): w.setVisible(not hide) + self._refresh_section_scroll("psth") def _toggle_metrics_panel(self, hide: bool) -> None: self.btn_hide_metrics.setText("Show" if hide else "Hide") @@ -1025,6 +3477,7 @@ def _toggle_metrics_panel(self, hide: bool) -> None: self.spin_metric_post1, ): w.setVisible(not hide) + self._refresh_section_scroll("psth") def _load_behavior_files(self) -> None: start_dir = self._settings.value("postprocess_last_dir", os.getcwd(), type=str) @@ -1107,7 +3560,7 @@ def _find_col(names: List[str]) -> Optional[int]: return None time_idx = header.index("time") if "time" in header else None - output_idx = _find_col(["dff", "z-score", "zscore", "z score", "output"]) + output_idx = _find_col(["dff", "z-score", "zscore", "z score", "output", "raw_signal", "raw_465"]) has_header = time_idx is not None and output_idx is not None raw_idx = _find_col(["raw", "raw_465", "signal", "signal_465"]) if has_header else None @@ -1168,6 +3621,8 @@ def _find_col(names: List[str]) -> Optional[int]: col = "z-score" elif col == "dff": col = "dFF" + elif col == "raw_signal": + col = "Raw signal (465)" output_label = f"Imported CSV ({col})" return ProcessedTrial( @@ -1224,64 +3679,739 @@ def _load_processed_h5(self, path: str) -> Optional[ProcessedTrial]: fs_target = float(g.attrs.get("fs_target", np.nan)) if hasattr(g, "attrs") else np.nan fs_used = float(g.attrs.get("fs_used", np.nan)) if hasattr(g, "attrs") else np.nan except Exception: - return None + return None + + return ProcessedTrial( + path=path, + channel_id="import", + time=t, + raw_signal=raw_sig, + raw_reference=raw_ref, + dio=dio, + dio_name=dio_name, + sig_f=None, + ref_f=None, + baseline_sig=None, + baseline_ref=None, + output=out, + output_label=output_label, + output_context=output_context, + artifact_regions_sec=None, + fs_actual=fs_actual, + fs_target=fs_target, + fs_used=fs_used, + ) + + def _refresh_behavior_list(self) -> None: + self.combo_behavior_name.clear() + if hasattr(self, "combo_behavior_analysis"): + self.combo_behavior_analysis.clear() + if not self._behavior_sources: + self._refresh_spatial_columns() + self._compute_spatial_heatmap() + self._update_data_availability() + return + behavior_names: set[str] = set() + for info in self._behavior_sources.values(): + behaviors = info.get("behaviors") or {} + behavior_names.update(str(k) for k in behaviors.keys()) + behaviors = sorted(list(behavior_names)) + for name in behaviors: + self.combo_behavior_name.addItem(name) + if hasattr(self, "combo_behavior_analysis"): + self.combo_behavior_analysis.addItem(name) + self.combo_behavior_from.clear() + self.combo_behavior_to.clear() + for name in behaviors: + self.combo_behavior_from.addItem(name) + self.combo_behavior_to.addItem(name) + + # Update the lists with numbered items + self._update_file_lists() + self._refresh_spatial_columns() + self._compute_spatial_heatmap() + self._update_data_availability() + self._update_status_strip() + + def _guess_spatial_column(self, columns: List[str], axis: str) -> Optional[str]: + if not columns: + return None + axis_l = axis.lower() + def _norm(value: str) -> str: + s = str(value).strip().lower() + s = re.sub(r"[\s_\-:/\(\)\[\]]+", " ", s) + return re.sub(r"\s+", " ", s).strip() + + # First choice: explicit EthoVision center labels (set as default). + preferred_center = { + "x": ["x center", "x_center", "x-center", "center x", "center_x", "center-x"], + "y": ["y center", "y_center", "y-center", "center y", "center_y", "center-y"], + }.get(axis_l, []) + preferred_center_norm = {_norm(v) for v in preferred_center} + for col in columns: + name_norm = _norm(str(col)) + if name_norm in preferred_center_norm: + return col + # Fallback center match for labels like "Center X (cm)" / "X center px". + for col in columns: + name_norm = _norm(str(col)) + if "center" in name_norm and axis_l in name_norm: + return col + # Second choice: exact column labels "X" / "Y" (case-insensitive). + for col in columns: + if _norm(str(col)) == axis_l: + return col + # Third choice: labels starting with X/Y token (e.g. "X nose", "Y_center"). + token_re = re.compile(rf"^{re.escape(axis_l)}(?:$|[\s_\-:/\(\[])") + for col in columns: + name = str(col).strip().lower() + if token_re.match(name): + return col + patterns = { + "x": ["x", "center x", "centrex", "nose x", "body x", "position x", "x center"], + "y": ["y", "center y", "centrey", "nose y", "body y", "position y", "y center"], + }.get(axis_l, []) + # Third choice: known exact labels. + for col in columns: + name = str(col).strip().lower() + for pat in patterns: + if pat == name: + return col + # Last choice: known partial labels. + for col in columns: + name = str(col).strip().lower() + for pat in patterns: + if pat in name: + return col + return columns[0] + + def _refresh_spatial_columns(self) -> None: + if not hasattr(self, "combo_spatial_x") or not hasattr(self, "combo_spatial_y"): + return + + prev_x = self.combo_spatial_x.currentText().strip() + prev_y = self.combo_spatial_y.currentText().strip() + cols: set[str] = set() + for info in self._behavior_sources.values(): + trajectory = info.get("trajectory") or {} + cols.update(str(k) for k in trajectory.keys()) + ordered = sorted(cols) + + for combo in (self.combo_spatial_x, self.combo_spatial_y): + combo.blockSignals(True) + combo.clear() + combo.addItems(ordered) + combo.blockSignals(False) + + if not ordered: + return + + if prev_x: + ix = self.combo_spatial_x.findText(prev_x) + if ix >= 0: + self.combo_spatial_x.setCurrentIndex(ix) + if prev_y: + iy = self.combo_spatial_y.findText(prev_y) + if iy >= 0: + self.combo_spatial_y.setCurrentIndex(iy) + + if not self.combo_spatial_x.currentText().strip(): + gx = self._guess_spatial_column(ordered, "x") + if gx: + self.combo_spatial_x.setCurrentText(gx) + if not self.combo_spatial_y.currentText().strip(): + gy = self._guess_spatial_column(ordered, "y") + if gy: + self.combo_spatial_y.setCurrentText(gy) + + if self.combo_spatial_x.currentText().strip() == self.combo_spatial_y.currentText().strip() and len(ordered) > 1: + for col in ordered: + if col != self.combo_spatial_x.currentText().strip(): + self.combo_spatial_y.setCurrentText(col) + break + + def _update_spatial_clip_enabled(self) -> None: + enabled = bool(self.cb_spatial_clip.isChecked() and self.cb_spatial_clip.isEnabled()) + self.spin_spatial_clip_low.setEnabled(enabled) + self.spin_spatial_clip_high.setEnabled(enabled) + + def _update_spatial_time_filter_enabled(self) -> None: + enabled = bool(self.cb_spatial_time_filter.isChecked() and self.cb_spatial_time_filter.isEnabled()) + self.spin_spatial_time_min.setEnabled(enabled) + self.spin_spatial_time_max.setEnabled(enabled) + + def _show_spatial_help(self) -> None: + QtWidgets.QMessageBox.information( + self, + "Spatial Heatmap Help", + "Top plot: occupancy heatmap.\n" + "Middle plot: activity heatmap (mean occupancy-normalized, mean velocity-normalized, or sum z-score/bin).\n" + "Bottom plot: velocity heatmap (mean speed per bin).\n" + "Mean z-score/bin (occupancy normalized) = sum(z*weight) / sum(weight).\n" + "Mean z-score/bin (velocity normalized) = sum(z*weight) / sum(speed*weight).\n" + "Enable time filter to include only samples within [start, end] seconds.\n" + "Use the right-side color cursors on each plot to set min/max display range interactively.", + ) + + def _selected_processed_for_spatial(self) -> List[ProcessedTrial]: + if not self._processed: + return [] + selected = self.list_preprocessed.selectedItems() if hasattr(self, "list_preprocessed") else [] + if not selected: + return list(self._processed) + id_map = {id(proc): proc for proc in self._processed} + picked: List[ProcessedTrial] = [] + for item in selected: + proc_id = item.data(QtCore.Qt.ItemDataRole.UserRole) if item else None + proc = id_map.get(proc_id) + if proc is not None: + picked.append(proc) + return picked or list(self._processed) + + def _spatial_weight_values( + self, + x: np.ndarray, + y: np.ndarray, + t: np.ndarray, + mode: str, + ) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]: + xx = np.asarray(x, float) + yy = np.asarray(y, float) + n = min(xx.size, yy.size) + if n <= 0: + return np.array([], float), np.array([], float), None + xx = xx[:n] + yy = yy[:n] + weights: Optional[np.ndarray] = None + if "time" in mode.lower() or "probability" in mode.lower(): + tt = np.asarray(t, float) if t is not None else np.array([], float) + if tt.size >= n: + tt = tt[:n] + else: + tt = np.array([], float) + if tt.size == n: + dt = np.diff(tt) + dt = dt[np.isfinite(dt) & (dt > 0)] + default_dt = float(np.nanmedian(dt)) if dt.size else 1.0 + w = np.full(n, default_dt, dtype=float) + if n > 1: + step = np.diff(tt, prepend=tt[0]) + step[~np.isfinite(step)] = default_dt + step[step <= 0] = default_dt + w = step + weights = w + else: + weights = np.ones(n, dtype=float) + return xx, yy, weights + + def _spatial_velocity_values( + self, + x: np.ndarray, + y: np.ndarray, + t: np.ndarray, + ) -> np.ndarray: + xx = np.asarray(x, float) + yy = np.asarray(y, float) + n = min(xx.size, yy.size) + if n <= 1: + return np.array([], float) + xx = xx[:n] + yy = yy[:n] + tt = np.asarray(t, float) if t is not None else np.array([], float) + if tt.size >= n: + tt = tt[:n] + dt = np.diff(tt) + use_time = True + else: + dt = np.ones(n - 1, dtype=float) + use_time = False + dx = np.diff(xx) + dy = np.diff(yy) + speed_steps = np.full(n - 1, np.nan, dtype=float) + valid = np.isfinite(dx) & np.isfinite(dy) & np.isfinite(dt) & (dt > 0) + if np.any(valid): + dist = np.sqrt(dx[valid] * dx[valid] + dy[valid] * dy[valid]) + speed_steps[valid] = dist / dt[valid] if use_time else dist + speed = np.full(n, np.nan, dtype=float) + speed[1:] = speed_steps + if np.isfinite(speed_steps).any(): + speed[0] = float(speed_steps[np.where(np.isfinite(speed_steps))[0][0]]) + return speed + + def _render_spatial_map( + self, + plot_widget: pg.PlotWidget, + image_item: pg.ImageItem, + heat: Optional[np.ndarray], + extent: Optional[Tuple[float, float, float, float]], + x_label: str, + y_label: str, + title: str, + ) -> None: + plot_widget.setTitle(title) + plot_widget.setLabel("bottom", x_label or "X") + plot_widget.setLabel("left", y_label or "Y") + invert_y = bool(self.cb_spatial_invert_y.isChecked()) if hasattr(self, "cb_spatial_invert_y") else False + vb = plot_widget.getViewBox() + if vb is not None: + vb.invertY(invert_y) + + if heat is None or heat.size == 0 or extent is None: + image_item.setImage(np.zeros((1, 1)), autoLevels=True) + image_item.setRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0)) + plot_widget.setXRange(0.0, 1.0, padding=0.0) + plot_widget.setYRange(0.0, 1.0, padding=0.0) + return + + cmap_name = str(self._style.get("heatmap_cmap", "viridis")) + try: + cmap = pg.colormap.get(cmap_name) + image_item.setLookupTable(cmap.getLookupTable()) + if image_item is getattr(self, "img_spatial_occupancy", None): + self.spatial_lut_occupancy.item.gradient.setColorMap(cmap) + elif image_item is getattr(self, "img_spatial_activity", None): + self.spatial_lut_activity.item.gradient.setColorMap(cmap) + elif image_item is getattr(self, "img_spatial_velocity", None): + self.spatial_lut_velocity.item.gradient.setColorMap(cmap) + except Exception: + pass - return ProcessedTrial( - path=path, - channel_id="import", - time=t, - raw_signal=raw_sig, - raw_reference=raw_ref, - dio=dio, - dio_name=dio_name, - sig_f=None, - ref_f=None, - baseline_sig=None, - baseline_ref=None, - output=out, - output_label=output_label, - output_context=output_context, - artifact_regions_sec=None, - fs_actual=fs_actual, - fs_target=fs_target, - fs_used=fs_used, + arr = np.asarray(heat, float) + image_item.setImage(arr, autoLevels=True) + hmin = self._style.get("heatmap_min", None) + hmax = self._style.get("heatmap_max", None) + if hmin is not None and hmax is not None: + image_item.setLevels([float(hmin), float(hmax)]) + xmin, xmax, ymin, ymax = extent + dx = max(1e-9, float(xmax - xmin)) + dy = max(1e-9, float(ymax - ymin)) + image_item.setRect(QtCore.QRectF(float(xmin), float(ymin), dx, dy)) + plot_widget.setXRange(float(xmin), float(xmax), padding=0.0) + plot_widget.setYRange(float(ymin), float(ymax), padding=0.0) + + def _render_spatial_heatmap( + self, + occupancy_heat: Optional[np.ndarray], + activity_heat: Optional[np.ndarray], + velocity_heat: Optional[np.ndarray], + extent: Optional[Tuple[float, float, float, float]], + x_label: str, + y_label: str, + occupancy_title: str, + activity_title: str, + velocity_title: str, + ) -> None: + self._render_spatial_map( + self.plot_spatial_occupancy, + self.img_spatial_occupancy, + occupancy_heat, + extent, + x_label, + y_label, + occupancy_title, + ) + self._render_spatial_map( + self.plot_spatial_activity, + self.img_spatial_activity, + activity_heat, + extent, + x_label, + y_label, + activity_title, + ) + self._render_spatial_map( + self.plot_spatial_velocity, + self.img_spatial_velocity, + velocity_heat, + extent, + x_label, + y_label, + velocity_title, ) - def _refresh_behavior_list(self) -> None: - self.combo_behavior_name.clear() + def _show_spatial_heatmap_panel(self) -> None: + if not hasattr(self, "spatial_plot_dialog"): + return + try: + self.spatial_plot_dialog.show() + self.spatial_plot_dialog.raise_() + self.spatial_plot_dialog.activateWindow() + except Exception: + pass + + def _on_compute_spatial_clicked(self) -> None: + self._compute_spatial_heatmap(show_panel=True) + + def _compute_spatial_heatmap(self, *_args: object, show_panel: bool = False) -> None: + self._queue_settings_save() + if not hasattr(self, "plot_spatial_occupancy"): + return + if show_panel: + self._show_spatial_heatmap_panel() + activity_mode_text = self.combo_spatial_activity_mode.currentText().strip().lower() + activity_mode = "mean" + if "velocity" in activity_mode_text: + activity_mode = "velocity" + elif "sum" in activity_mode_text: + activity_mode = "sum" + + def _activity_title_default() -> str: + if activity_mode == "velocity": + return "Spatial activity (mean z-score/bin, velocity normalized)" + if activity_mode == "sum": + return "Spatial activity (sum z-score/bin)" + return "Spatial activity (mean z-score/bin, occupancy normalized)" + + def _clear_spatial(msg: str, x_label: str = "", y_label: str = "") -> None: + self._last_spatial_occupancy_map = None + self._last_spatial_activity_map = None + self._last_spatial_velocity_map = None + self._last_spatial_extent = None + self.lbl_spatial_msg.setText(msg) + self._render_spatial_heatmap( + None, + None, + None, + None, + x_label, + y_label, + "Spatial occupancy", + _activity_title_default(), + "Spatial velocity (mean speed/bin)", + ) + + if not self._processed: + _clear_spatial("Load processed files to compute spatial heatmap.") + return if not self._behavior_sources: + _clear_spatial("Load behavior file(s) with trajectory columns.") return - any_info = next(iter(self._behavior_sources.values())) - behaviors = sorted(list((any_info.get("behaviors") or {}).keys())) - for name in behaviors: - self.combo_behavior_name.addItem(name) - self.combo_behavior_from.clear() - self.combo_behavior_to.clear() - for name in behaviors: - self.combo_behavior_from.addItem(name) - self.combo_behavior_to.addItem(name) - # Update the lists with numbered items - self._update_file_lists() + x_col = self.combo_spatial_x.currentText().strip() + y_col = self.combo_spatial_y.currentText().strip() + if not x_col or not y_col: + _clear_spatial("Select X and Y trajectory columns.", x_col, y_col) + return + + selected = self._selected_processed_for_spatial() + mode = self.combo_spatial_weight.currentText().strip() + use_time_filter = bool(self.cb_spatial_time_filter.isChecked()) + time_min = float(self.spin_spatial_time_min.value()) + time_max = float(self.spin_spatial_time_max.value()) + if use_time_filter and time_max <= time_min: + _clear_spatial("Invalid time range: end must be greater than start.", x_col, y_col) + return + bins_x = int(self.spin_spatial_bins_x.value()) + bins_y = int(self.spin_spatial_bins_y.value()) + + rows: List[Dict[str, np.ndarray]] = [] + all_x: List[np.ndarray] = [] + all_y: List[np.ndarray] = [] + skipped_missing_time = 0 + for proc in selected: + info = self._match_behavior_source(proc) + if not info: + continue + traj = info.get("trajectory") or {} + if x_col not in traj or y_col not in traj: + continue + x = np.asarray(traj.get(x_col, np.array([], float)), float) + y = np.asarray(traj.get(y_col, np.array([], float)), float) + t = np.asarray(info.get("trajectory_time", np.array([], float)), float) + if t.size == 0: + t = np.asarray(info.get("time", np.array([], float)), float) + n = min(x.size, y.size) + if n <= 2: + continue + xx = x[:n] + yy = y[:n] + tt_full = t[:n] if t.size >= n else np.array([], float) + finite = np.isfinite(xx) & np.isfinite(yy) + if use_time_filter: + if tt_full.size != n: + skipped_missing_time += 1 + continue + finite = finite & np.isfinite(tt_full) & (tt_full >= time_min) & (tt_full <= time_max) + if not np.any(finite): + continue + + xx_occ = xx[finite] + yy_occ = yy[finite] + tt_occ = tt_full[finite] if tt_full.size == n else np.array([], float) + vel_occ = self._spatial_velocity_values(xx_occ, yy_occ, tt_occ) + if vel_occ.size != xx_occ.size: + vel_occ = np.full(xx_occ.shape, np.nan, dtype=float) + + act_vals = np.array([], float) + if proc.output is not None: + yy_out = np.asarray(proc.output, float) + is_zscore_output = "zscore" in (proc.output_label or "").lower() or "z-score" in (proc.output_label or "").lower() + if is_zscore_output: + yy_z = yy_out + else: + yy_z = np.asarray(yy_out, float).copy() + m_out = np.isfinite(yy_z) + if np.any(m_out): + mu = float(np.nanmean(yy_z[m_out])) + sd = float(np.nanstd(yy_z[m_out])) + if not np.isfinite(sd) or sd <= 1e-12: + sd = 1.0 + yy_z = (yy_z - mu) / sd + if proc.time is not None and np.asarray(proc.time, float).size == yy_out.size and t.size >= n: + tt_proc = np.asarray(proc.time, float) + act_full = np.interp(t[:n], tt_proc, yy_z, left=np.nan, right=np.nan) + act_vals = np.asarray(act_full[finite], float) + elif yy_z.size >= n: + act_vals = np.asarray(yy_z[:n][finite], float) + + rows.append( + { + "x_occ": xx_occ, + "y_occ": yy_occ, + "t_occ": tt_occ, + "vel_occ": vel_occ, + "x_act": xx_occ.copy(), + "y_act": yy_occ.copy(), + "t_act": tt_occ.copy(), + "vel_act": vel_occ.copy(), + "act": act_vals, + } + ) + all_x.append(xx_occ) + all_y.append(yy_occ) + + if not rows or not all_x or not all_y: + if use_time_filter: + _clear_spatial("No trajectory samples in the selected time range.", x_col, y_col) + else: + _clear_spatial("No matching trajectory data found for selected files.", x_col, y_col) + return + + x_all = np.concatenate(all_x) + y_all = np.concatenate(all_y) + if self.cb_spatial_clip.isChecked(): + lo = float(self.spin_spatial_clip_low.value()) + hi = float(self.spin_spatial_clip_high.value()) + if hi <= lo: + hi = min(100.0, lo + 1.0) + xmin, xmax = np.nanpercentile(x_all, [lo, hi]) + ymin, ymax = np.nanpercentile(y_all, [lo, hi]) + else: + xmin, xmax = float(np.nanmin(x_all)), float(np.nanmax(x_all)) + ymin, ymax = float(np.nanmin(y_all)), float(np.nanmax(y_all)) + if not np.isfinite(xmin) or not np.isfinite(xmax) or xmin == xmax: + xmin, xmax = 0.0, 1.0 + if not np.isfinite(ymin) or not np.isfinite(ymax) or ymin == ymax: + ymin, ymax = 0.0, 1.0 + + occ_maps: List[np.ndarray] = [] + act_maps: List[np.ndarray] = [] + vel_maps: List[np.ndarray] = [] + for row in rows: + xx, yy, weights = self._spatial_weight_values(row["x_occ"], row["y_occ"], row["t_occ"], mode) + if xx.size == 0 or yy.size == 0: + continue + occ_hist, _, _ = np.histogram2d( + xx, + yy, + bins=[bins_x, bins_y], + range=[[xmin, xmax], [ymin, ymax]], + weights=weights, + ) + occ_hist = np.asarray(occ_hist, float) + if "probability" in mode.lower(): + denom = float(np.nansum(occ_hist)) + if denom > 0: + occ_hist = (occ_hist / denom) * 100.0 + occ_maps.append(occ_hist) + + vel = np.asarray(row.get("vel_occ", np.array([], float)), float) + if vel.size > 0: + xv = np.asarray(row["x_occ"], float) + yv = np.asarray(row["y_occ"], float) + tv = np.asarray(row["t_occ"], float) + m_vel = np.isfinite(vel) & np.isfinite(xv) & np.isfinite(yv) + if np.any(m_vel): + xv = xv[m_vel] + yv = yv[m_vel] + vel = np.clip(vel[m_vel], a_min=0.0, a_max=None) + tv = tv[m_vel] if tv.size == m_vel.size else np.array([], float) + xx_v, yy_v, w_v = self._spatial_weight_values(xv, yv, tv, mode) + n_v = min(xx_v.size, yy_v.size, vel.size) + if n_v > 0: + xx_v = xx_v[:n_v] + yy_v = yy_v[:n_v] + vel = vel[:n_v] + vel_w = w_v[:n_v] if w_v is not None and w_v.size >= n_v else np.ones(n_v, dtype=float) + vel_num, _, _ = np.histogram2d( + xx_v, + yy_v, + bins=[bins_x, bins_y], + range=[[xmin, xmax], [ymin, ymax]], + weights=vel * vel_w, + ) + vel_den, _, _ = np.histogram2d( + xx_v, + yy_v, + bins=[bins_x, bins_y], + range=[[xmin, xmax], [ymin, ymax]], + weights=vel_w, + ) + with np.errstate(divide="ignore", invalid="ignore"): + vel_hist = np.divide(vel_num, vel_den, out=np.full_like(vel_num, np.nan), where=vel_den > 0) + vel_maps.append(np.asarray(vel_hist, float)) + + act = np.asarray(row.get("act", np.array([], float)), float) + if act.size == 0: + continue + xa = np.asarray(row["x_act"], float) + ya = np.asarray(row["y_act"], float) + ta = np.asarray(row["t_act"], float) + va = np.asarray(row.get("vel_act", np.array([], float)), float) + m_act = np.isfinite(act) & np.isfinite(xa) & np.isfinite(ya) + if not np.any(m_act): + continue + xa = xa[m_act] + ya = ya[m_act] + act = act[m_act] + ta = ta[m_act] if ta.size == m_act.size else np.array([], float) + if va.size == m_act.size: + va = va[m_act] + else: + va = np.full(act.shape, np.nan, dtype=float) + xx_a, yy_a, w_a = self._spatial_weight_values(xa, ya, ta, mode) + if xx_a.size == 0 or yy_a.size == 0 or act.size == 0: + continue + n_a = min(xx_a.size, yy_a.size, act.size) + xx_a = xx_a[:n_a] + yy_a = yy_a[:n_a] + act = act[:n_a] + va = va[:n_a] if va.size >= n_a else np.full(n_a, np.nan, dtype=float) + den_w = w_a[:n_a] if w_a is not None and w_a.size >= n_a else np.ones(n_a, dtype=float) + num_hist, _, _ = np.histogram2d( + xx_a, + yy_a, + bins=[bins_x, bins_y], + range=[[xmin, xmax], [ymin, ymax]], + weights=act * den_w, + ) + + if activity_mode == "sum": + act_hist = np.asarray(num_hist, float) + elif activity_mode == "velocity": + vel_weights = np.nan_to_num(np.clip(va, a_min=0.0, a_max=None), nan=0.0, posinf=0.0, neginf=0.0) * den_w + vel_den, _, _ = np.histogram2d( + xx_a, + yy_a, + bins=[bins_x, bins_y], + range=[[xmin, xmax], [ymin, ymax]], + weights=vel_weights, + ) + with np.errstate(divide="ignore", invalid="ignore"): + act_hist = np.divide(num_hist, vel_den, out=np.full_like(num_hist, np.nan), where=vel_den > 1e-12) + else: + den_hist, _, _ = np.histogram2d( + xx_a, + yy_a, + bins=[bins_x, bins_y], + range=[[xmin, xmax], [ymin, ymax]], + weights=den_w, + ) + with np.errstate(divide="ignore", invalid="ignore"): + act_hist = np.divide(num_hist, den_hist, out=np.full_like(num_hist, np.nan), where=den_hist > 0) + act_maps.append(np.asarray(act_hist, float)) + + if not occ_maps: + _clear_spatial("Could not compute heatmap for selected trajectories.", x_col, y_col) + return + + def _nanmean_stack_no_warning(maps: List[np.ndarray]) -> Optional[np.ndarray]: + if not maps: + return None + stack = np.asarray(np.stack(maps, axis=0), float) + valid = np.isfinite(stack) + counts = np.sum(valid, axis=0) + sums = np.nansum(stack, axis=0) + return np.divide(sums, counts, out=np.full_like(sums, np.nan, dtype=float), where=counts > 0) + + occupancy_map = _nanmean_stack_no_warning(occ_maps) + if occupancy_map is None: + _clear_spatial("Could not compute heatmap for selected trajectories.", x_col, y_col) + return + activity_map = _nanmean_stack_no_warning(act_maps) + velocity_map = _nanmean_stack_no_warning(vel_maps) + sigma = float(self.spin_spatial_smooth.value()) + if sigma > 0: + from scipy.ndimage import gaussian_filter + + def _smooth_map(arr: np.ndarray) -> np.ndarray: + a = np.asarray(arr, float) + if not np.any(~np.isfinite(a)): + return gaussian_filter(a, sigma=max(0.0, sigma), mode="nearest") + valid = np.isfinite(a).astype(float) + num = gaussian_filter(np.nan_to_num(a, nan=0.0), sigma=max(0.0, sigma), mode="nearest") + den = gaussian_filter(valid, sigma=max(0.0, sigma), mode="nearest") + return np.divide(num, den, out=np.full_like(num, np.nan), where=den > 1e-9) + + occupancy_map = _smooth_map(occupancy_map) + if activity_map is not None: + activity_map = _smooth_map(activity_map) + if velocity_map is not None: + velocity_map = _smooth_map(velocity_map) + if self.cb_spatial_log.isChecked(): + occupancy_map = np.log1p(np.clip(occupancy_map, a_min=0.0, a_max=None)) + if activity_map is not None: + activity_map = np.sign(activity_map) * np.log1p(np.abs(activity_map)) + if velocity_map is not None: + velocity_map = np.log1p(np.clip(velocity_map, a_min=0.0, a_max=None)) + + self._last_spatial_occupancy_map = occupancy_map + self._last_spatial_activity_map = activity_map + self._last_spatial_velocity_map = velocity_map + self._last_spatial_extent = (float(xmin), float(xmax), float(ymin), float(ymax)) + occ_title = f"Spatial occupancy ({mode.lower()})" + if len(occ_maps) > 1: + occ_title += f" - avg {len(occ_maps)} files" + act_title = _activity_title_default() + if len(act_maps) > 1: + act_title += f" - avg {len(act_maps)} files" + vel_title = "Spatial velocity (mean speed/bin)" + if len(vel_maps) > 1: + vel_title += f" - avg {len(vel_maps)} files" + self._render_spatial_heatmap( + occupancy_map, + activity_map, + velocity_map, + self._last_spatial_extent, + x_col, + y_col, + occ_title, + act_title, + vel_title, + ) + time_txt = f", time={time_min:g}-{time_max:g}s" if use_time_filter else "" + skip_txt = f", skipped {skipped_missing_time} file(s) without valid time" if skipped_missing_time > 0 else "" + self.lbl_spatial_msg.setText( + f"Rendered occupancy {len(occ_maps)} map(s), activity {len(act_maps)} map(s), velocity {len(vel_maps)} map(s), bins={bins_x}x{bins_y}{time_txt}{skip_txt}." + ) def _update_file_lists(self) -> None: """Update the preprocessed files and behaviors lists with numbered entries.""" self.list_preprocessed.clear() for i, proc in enumerate(self._processed, 1): filename = os.path.splitext(os.path.basename(proc.path))[0] - # Remove _AIN01/_AIN02 suffix for matching - filename_clean = filename.replace('_AIN01', '').replace('_AIN02', '') + filename_clean = _strip_ain_suffix(filename) item = QtWidgets.QListWidgetItem(f"{i}. {filename_clean}") item.setData(QtCore.Qt.ItemDataRole.UserRole, id(proc)) self.list_preprocessed.addItem(item) self.list_behaviors.clear() for i, (stem, _) in enumerate(self._behavior_sources.items(), 1): - # Remove _AIN01/_AIN02 suffix for matching - stem_clean = stem.replace('_AIN01', '').replace('_AIN02', '') + stem_clean = _strip_ain_suffix(stem) item = QtWidgets.QListWidgetItem(f"{i}. {stem_clean}") item.setData(QtCore.Qt.ItemDataRole.UserRole, stem) self.list_behaviors.addItem(item) + self._refresh_signal_file_combo() def _sync_processed_order_from_list(self) -> None: new_order: List[ProcessedTrial] = [] @@ -1295,6 +4425,8 @@ def _sync_processed_order_from_list(self) -> None: if new_order and len(new_order) == len(self._processed): self._processed = new_order self._update_file_lists() + self._compute_spatial_heatmap() + self._update_status_strip() def _sync_behavior_order_from_list(self) -> None: keys: List[str] = [] @@ -1306,6 +4438,8 @@ def _sync_behavior_order_from_list(self) -> None: if keys and len(keys) == len(self._behavior_sources): self._behavior_sources = {k: self._behavior_sources[k] for k in keys} self._update_file_lists() + self._compute_spatial_heatmap() + self._update_status_strip() def _move_selected_up(self) -> None: """Move selected items up in the list.""" @@ -1362,12 +4496,12 @@ def _auto_match_files(self) -> None: proc_names = [] for proc in self._processed: filename = os.path.splitext(os.path.basename(proc.path))[0] - filename_clean = filename.replace('_AIN01', '').replace('_AIN02', '') + filename_clean = _strip_ain_suffix(filename) proc_names.append(filename_clean) beh_names = [] for stem in self._behavior_sources.keys(): - stem_clean = stem.replace('_AIN01', '').replace('_AIN02', '') + stem_clean = _strip_ain_suffix(stem) beh_names.append(stem_clean) # Simple auto-matching: sort both lists and try to match by position @@ -1393,9 +4527,9 @@ def _auto_match_files(self) -> None: # Reorder behavior sources to match processed files new_behavior_order = [] for proc in self._processed: - proc_clean = os.path.splitext(os.path.basename(proc.path))[0].replace('_AIN01', '').replace('_AIN02', '') + proc_clean = _strip_ain_suffix(os.path.splitext(os.path.basename(proc.path))[0]) for stem, data in self._behavior_sources.items(): - stem_clean = stem.replace('_AIN01', '').replace('_AIN02', '') + stem_clean = _strip_ain_suffix(stem) if stem_clean == proc_clean: new_behavior_order.append((stem, data)) break @@ -1407,6 +4541,8 @@ def _auto_match_files(self) -> None: self._behavior_sources = dict(new_behavior_order) self._update_file_lists() + self._compute_spatial_heatmap() + self._update_status_strip() def _remove_selected_preprocessed(self) -> None: selected = self.list_preprocessed.selectedItems() @@ -1416,7 +4552,12 @@ def _remove_selected_preprocessed(self) -> None: for row in rows: if 0 <= row < len(self._processed): del self._processed[row] + if not self._autosave_restoring: + self._project_dirty = True self._update_file_lists() + self._compute_spatial_heatmap() + self._update_data_availability() + self._update_status_strip() def _remove_selected_behaviors(self) -> None: selected = self.list_behaviors.selectedItems() @@ -1429,7 +4570,11 @@ def _remove_selected_behaviors(self) -> None: key = keys[row] if key in self._behavior_sources: del self._behavior_sources[key] + if not self._autosave_restoring: + self._project_dirty = True self._refresh_behavior_list() + self._update_data_availability() + self._update_status_strip() # ---- PSTH compute ---- @@ -1437,9 +4582,9 @@ def _match_behavior_source(self, proc: ProcessedTrial) -> Optional[Dict[str, Any stem = os.path.splitext(os.path.basename(proc.path))[0] info = self._behavior_sources.get(stem, None) if info is None: - stem_clean = stem.replace('_AIN01', '').replace('_AIN02', '') + stem_clean = _strip_ain_suffix(stem) for key, val in self._behavior_sources.items(): - key_clean = key.replace('_AIN01', '').replace('_AIN02', '') + key_clean = _strip_ain_suffix(key) if key_clean == stem_clean: info = val break @@ -1456,15 +4601,40 @@ def _match_behavior_source(self, proc: ProcessedTrial) -> Optional[Dict[str, Any info = next(iter(self._behavior_sources.values())) return info + def _extract_behavior_events(self, info: Dict[str, Any], behavior_name: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + behaviors = info.get("behaviors") or {} + if behavior_name not in behaviors: + return np.array([], float), np.array([], float), np.array([], float) + kind = str(info.get("kind", _BEHAVIOR_PARSE_BINARY)) + if kind == _BEHAVIOR_PARSE_TIMESTAMPS: + on = np.asarray(behaviors[behavior_name], float) + on = on[np.isfinite(on)] + if on.size == 0: + return np.array([], float), np.array([], float), np.array([], float) + on = np.sort(np.unique(on)) + off = on.copy() + dur = np.full(on.shape, np.nan, dtype=float) + return on, off, dur + t = np.asarray(info.get("time", np.array([], float)), float) + if t.size == 0: + return np.array([], float), np.array([], float), np.array([], float) + return _extract_onsets_offsets(t, np.asarray(behaviors[behavior_name], float), threshold=0.5) + def _get_events_for_proc(self, proc: ProcessedTrial) -> Tuple[np.ndarray, np.ndarray]: align = self.combo_align.currentText() - if align.startswith("DIO"): + if _is_doric_channel_align(align): dio_name = self.combo_dio.currentText().strip() if not dio_name and proc.dio is None: return np.array([], float), np.array([], float) - if proc.dio is not None and proc.time is not None: + use_embedded = ( + proc.dio is not None + and proc.time is not None + and (not dio_name or dio_name == (proc.dio_name or "")) + ) + + if use_embedded: t = np.asarray(proc.time, float) x = np.asarray(proc.dio, float) else: @@ -1476,7 +4646,7 @@ def _get_events_for_proc(self, proc: ProcessedTrial) -> Tuple[np.ndarray, np.nda t, x = self._dio_cache[key] polarity = self.combo_dio_polarity.currentText() align_edge = self.combo_dio_align.currentText() - sig = np.asarray(x, float) + sig = (np.asarray(x, float) > 0.5).astype(float) if polarity.startswith("Event low"): sig = 1.0 - sig on, off, dur = _extract_onsets_offsets(t, sig, threshold=0.5) @@ -1488,9 +4658,8 @@ def _get_events_for_proc(self, proc: ProcessedTrial) -> Tuple[np.ndarray, np.nda info = self._match_behavior_source(proc) if not info: return np.array([], float), np.array([], float) - t = np.asarray(info.get("time", np.array([], float)), float) behaviors = info.get("behaviors") or {} - if t.size == 0 or not behaviors: + if not behaviors: return np.array([], float), np.array([], float) align_mode = self.combo_behavior_align.currentText() @@ -1499,8 +4668,8 @@ def _get_events_for_proc(self, proc: ProcessedTrial) -> Tuple[np.ndarray, np.nda beh_b = self.combo_behavior_to.currentText().strip() if beh_a not in behaviors or beh_b not in behaviors: return np.array([], float), np.array([], float) - on_a, off_a, _ = _extract_onsets_offsets(t, behaviors[beh_a], threshold=0.5) - on_b, _, dur_b = _extract_onsets_offsets(t, behaviors[beh_b], threshold=0.5) + on_a, off_a, _ = self._extract_behavior_events(info, beh_a) + on_b, _, dur_b = self._extract_behavior_events(info, beh_b) if on_a.size == 0 or on_b.size == 0: return np.array([], float), np.array([], float) gap = float(self.spin_transition_gap.value()) @@ -1520,13 +4689,52 @@ def _get_events_for_proc(self, proc: ProcessedTrial) -> Tuple[np.ndarray, np.nda beh = self.combo_behavior_name.currentText().strip() if not beh and behaviors: beh = next(iter(behaviors.keys())) - if beh not in behaviors: + on, off, dur = self._extract_behavior_events(info, beh) + if on.size == 0: return np.array([], float), np.array([], float) - on, off, dur = _extract_onsets_offsets(t, behaviors[beh], threshold=0.5) if align_mode.endswith("offset"): return off, dur return on, dur + def _group_close_events( + self, + times: np.ndarray, + durations: np.ndarray, + window_s: float, + ) -> Tuple[np.ndarray, np.ndarray]: + t = np.asarray(times, float) + d = np.asarray(durations, float) + if t.size < 2 or window_s <= 0: + return t, d + + grouped_t: List[float] = [] + grouped_d: List[float] = [] + + start = float(t[0]) + prev = float(t[0]) + d0 = float(d[0]) if d.size else np.nan + cluster_end = (start + max(0.0, d0)) if np.isfinite(d0) else np.nan + + for i in range(1, t.size): + ti = float(t[i]) + di = float(d[i]) if i < d.size else np.nan + if (ti - prev) <= window_s: + if np.isfinite(di): + end_i = ti + max(0.0, di) + cluster_end = end_i if not np.isfinite(cluster_end) else max(cluster_end, end_i) + prev = ti + continue + + grouped_t.append(start) + grouped_d.append(max(0.0, cluster_end - start) if np.isfinite(cluster_end) else np.nan) + start = ti + prev = ti + cluster_end = (ti + max(0.0, di)) if np.isfinite(di) else np.nan + + grouped_t.append(start) + grouped_d.append(max(0.0, cluster_end - start) if np.isfinite(cluster_end) else np.nan) + return np.asarray(grouped_t, float), np.asarray(grouped_d, float) + def _filter_events(self, times: np.ndarray, durations: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: if not self.cb_filter_events.isChecked(): return np.asarray(times, float), np.asarray(durations, float) @@ -1537,6 +4745,18 @@ def _filter_events(self, times: np.ndarray, durations: np.ndarray) -> Tuple[np.n if times.size == 0: return times, durations + finite = np.isfinite(times) + if not np.any(finite): + return np.array([], float), np.array([], float) + times = times[finite] + durations = durations[finite] + order = np.argsort(times) + times = times[order] + durations = durations[order] + + group_window_s = max(0.0, float(self.spin_group_window.value())) + times, durations = self._group_close_events(times, durations, group_window_s) + start_idx = int(self.spin_event_start.value()) end_idx = int(self.spin_event_end.value()) if start_idx < 1: @@ -1567,84 +4787,755 @@ def _update_trace_preview(self) -> None: if not self._processed: self.curve_trace.setData([], []) self.curve_behavior.setData([], []) + self.curve_peak_markers.setData([], []) for ln in self.event_lines: self.plot_trace.removeItem(ln) self.event_lines = [] + for lab in self._event_labels: + self.plot_trace.removeItem(lab) + self._event_labels = [] + for reg in self._event_regions: + self.plot_trace.removeItem(reg) + self._event_regions = [] + for ln in self._signal_peak_lines: + self.plot_trace.removeItem(ln) + self._signal_peak_lines = [] + return + + proc = self._processed[0] + t = proc.time + y = proc.output if proc.output is not None else np.full_like(t, np.nan) + + self.curve_trace.setData(t, y, connect="finite", skipFiniteCheck=True) + self._update_behavior_overlay(proc) + + # draw event lines if possible + for ln in self.event_lines: + self.plot_trace.removeItem(ln) + self.event_lines = [] + for lab in self._event_labels: + self.plot_trace.removeItem(lab) + self._event_labels = [] + for reg in self._event_regions: + self.plot_trace.removeItem(reg) + self._event_regions = [] + + ev, dur = self._get_events_for_proc(proc) + ev, dur = self._filter_events(ev, dur) + if ev.size: + # limit lines to a reasonable amount for UI + ev = ev[:200] + max_y = float(np.nanmax(y)) if np.isfinite(np.nanmax(y)) else 0.0 + for i, et in enumerate(ev, start=1): + ln = pg.InfiniteLine(pos=float(et), angle=90, pen=pg.mkPen((220, 220, 220), width=1.0, style=QtCore.Qt.PenStyle.DashLine)) + self.plot_trace.addItem(ln) + self.event_lines.append(ln) + label = pg.TextItem(str(i), color=(200, 200, 200)) + label.setPos(float(et), max_y) + self.plot_trace.addItem(label) + self._event_labels.append(label) + if dur is not None and i - 1 < dur.size and np.isfinite(dur[i - 1]): + t0 = float(et) + t1 = float(et + dur[i - 1]) + reg = pg.LinearRegionItem(values=(t0, t1), brush=(200, 200, 200, 40), movable=False) + self.plot_trace.addItem(reg) + self._event_regions.append(reg) + self._refresh_signal_overlay() + + def _update_behavior_overlay(self, proc: ProcessedTrial) -> None: + if not proc or proc.time is None: + self.curve_behavior.setData([], []) + return + if not self.combo_align.currentText().startswith("Behavior"): + self.curve_behavior.setData([], []) + return + info = self._match_behavior_source(proc) + if not info: + self.curve_behavior.setData([], []) + return + behaviors = info.get("behaviors") or {} + beh = self.combo_behavior_name.currentText().strip() + if not beh and behaviors: + beh = next(iter(behaviors.keys())) + if beh not in behaviors: + self.curve_behavior.setData([], []) + return + t_proc = np.asarray(proc.time, float) + if t_proc.size == 0: + self.curve_behavior.setData([], []) + return + kind = str(info.get("kind", _BEHAVIOR_PARSE_BINARY)) + if kind == _BEHAVIOR_PARSE_TIMESTAMPS: + events = np.asarray(behaviors[beh], float) + events = events[np.isfinite(events)] + if events.size == 0: + self.curve_behavior.setData([], []) + return + events = np.sort(np.unique(events)) + marker = np.zeros_like(t_proc, dtype=float) + for ev in events: + pos = int(np.searchsorted(t_proc, ev, side="left")) + if pos <= 0: + idx = 0 + elif pos >= t_proc.size: + idx = t_proc.size - 1 + else: + idx = pos if abs(float(t_proc[pos] - ev)) <= abs(float(t_proc[pos - 1] - ev)) else (pos - 1) + marker[idx] = 1.0 + self.curve_behavior.setData(t_proc, marker, connect="finite", skipFiniteCheck=True) + return + + t = np.asarray(info.get("time", np.array([], float)), float) + if t.size == 0: + self.curve_behavior.setData([], []) + return + b = np.asarray(behaviors[beh], float) + b_interp = np.interp(t_proc, t, b) + self.curve_behavior.setData(t_proc, b_interp, connect="finite", skipFiniteCheck=True) + + def _resolve_signal_detection_targets(self) -> List[Tuple[str, np.ndarray, np.ndarray]]: + targets: List[Tuple[str, np.ndarray, np.ndarray]] = [] + source_mode = self.combo_signal_source.currentText() + if source_mode.startswith("Use PSTH input trace"): + if self._last_mat is None or self._last_tvec is None or self._last_mat.size == 0: + return [] + trace = np.nanmean(self._last_mat, axis=0) + targets.append(("psth_trace", np.asarray(self._last_tvec, float), np.asarray(trace, float))) + return targets + + if not self._processed: + return [] + + pooled = self.combo_signal_scope.currentText() == "Pooled" + if pooled: + for proc in self._processed: + if proc.time is None or proc.output is None: + continue + file_id = os.path.splitext(os.path.basename(proc.path))[0] if proc.path else "import" + targets.append((file_id, np.asarray(proc.time, float), np.asarray(proc.output, float))) + return targets + + if self.combo_signal_file.count() == 0: + return [] + idx = self.combo_signal_file.currentIndex() + idx = max(0, min(idx, len(self._processed) - 1)) + proc = self._processed[idx] + if proc.time is None or proc.output is None: + return [] + file_id = os.path.splitext(os.path.basename(proc.path))[0] if proc.path else "import" + targets.append((file_id, np.asarray(proc.time, float), np.asarray(proc.output, float))) + return targets + + def _preprocess_signal_for_peaks(self, t: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + t = np.asarray(t, float) + y = np.asarray(y, float) + m = np.isfinite(t) & np.isfinite(y) + t = t[m] + y = y[m] + if t.size < 3: + return np.array([], float), np.array([], float) + + dt = float(np.nanmedian(np.diff(t))) if t.size > 2 else np.nan + if not np.isfinite(dt) or dt <= 0: + dt = 1.0 + + baseline_mode = self.combo_peak_baseline.currentText() + baseline_window_sec = max(0.1, float(self.spin_peak_baseline_window.value())) + win = max(3, int(round(baseline_window_sec / dt))) + if win % 2 == 0: + win += 1 + + y_proc = y.copy() + if baseline_mode.endswith("rolling median"): + try: + from scipy.ndimage import median_filter + y_proc = y - median_filter(y, size=win, mode="nearest") + except Exception: + pass + elif baseline_mode.endswith("rolling mean"): + try: + from scipy.ndimage import uniform_filter1d + y_proc = y - uniform_filter1d(y, size=win, mode="nearest") + except Exception: + pass + + sigma_sec = max(0.0, float(self.spin_peak_smooth.value())) + if sigma_sec > 0: + try: + from scipy.ndimage import gaussian_filter1d + sigma_samp = sigma_sec / dt + y_proc = gaussian_filter1d(y_proc, sigma=sigma_samp, mode="nearest") + except Exception: + pass + + return t, y_proc + + def _refresh_signal_overlay(self) -> None: + for ln in self._signal_peak_lines: + try: + self.plot_trace.removeItem(ln) + except Exception: + pass + self._signal_peak_lines = [] + self.curve_peak_markers.setData([], []) + + if not self.cb_peak_overlay.isChecked(): + return + if not self.last_signal_events or not self._processed: + return + + current_file = os.path.splitext(os.path.basename(self._processed[0].path))[0] if self._processed[0].path else "import" + file_ids = self.last_signal_events.get("file_ids", []) + times = np.asarray(self.last_signal_events.get("peak_times_sec", np.array([], float)), float) + heights = np.asarray(self.last_signal_events.get("peak_heights", np.array([], float)), float) + if times.size == 0 or heights.size == 0: + return + if file_ids and len(file_ids) == times.size: + mask = np.asarray([fid == current_file or fid == "psth_trace" for fid in file_ids], bool) + times = times[mask] + heights = heights[mask] + if times.size == 0: + return + self.curve_peak_markers.setData(times, heights, connect="finite", skipFiniteCheck=True) + for t0 in times[:600]: + ln = pg.InfiniteLine( + pos=float(t0), + angle=90, + pen=pg.mkPen((240, 120, 80), width=1.0, style=QtCore.Qt.PenStyle.DotLine), + ) + self.plot_trace.addItem(ln) + self._signal_peak_lines.append(ln) + + def _detect_signal_events(self) -> None: + self.last_signal_events = None + targets = self._resolve_signal_detection_targets() + if not targets: + self.statusUpdate.emit("No signal available for peak detection.", 5000) + self.tbl_signal_metrics.setRowCount(0) + self._refresh_signal_overlay() + return + + all_times: List[float] = [] + all_idx: List[int] = [] + all_heights: List[float] = [] + all_proms: List[float] = [] + all_widths_sec: List[float] = [] + all_auc: List[float] = [] + all_file_ids: List[str] = [] + + for file_id, t_raw, y_raw in targets: + t, y = self._preprocess_signal_for_peaks(t_raw, y_raw) + if t.size < 5: + continue + dt = float(np.nanmedian(np.diff(t))) + if not np.isfinite(dt) or dt <= 0: + continue + + prominence = max(0.0, float(self.spin_peak_prominence.value())) + min_height = float(self.spin_peak_height.value()) + min_distance_sec = max(0.0, float(self.spin_peak_distance.value())) + min_dist_samples = max(1, int(round(min_distance_sec / dt))) if min_distance_sec > 0 else None + + kwargs: Dict[str, object] = {} + if prominence > 0: + kwargs["prominence"] = prominence + if min_height > 0: + kwargs["height"] = min_height + if min_dist_samples is not None: + kwargs["distance"] = min_dist_samples + + try: + from scipy.signal import find_peaks, peak_widths + peaks, props = find_peaks(y, **kwargs) + except Exception as exc: + self.statusUpdate.emit(f"Peak detection failed: {exc}", 5000) + self.tbl_signal_metrics.setRowCount(0) + self._refresh_signal_overlay() + return + + if peaks.size == 0: + continue + + p_heights = y[peaks] + p_proms = np.asarray(props.get("prominences", np.full(peaks.size, np.nan)), float) + try: + widths_samp = peak_widths(y, peaks, rel_height=0.5)[0] + widths_sec = np.asarray(widths_samp, float) * dt + except Exception: + widths_sec = np.full(peaks.size, np.nan, float) + + auc_half_win = max(0.0, float(self.spin_peak_auc_window.value())) + auc_samp = int(round(auc_half_win / dt)) + auc_vals: List[float] = [] + for pk in peaks: + if auc_samp <= 0: + auc_vals.append(np.nan) + continue + i0 = max(0, int(pk - auc_samp)) + i1 = min(y.size, int(pk + auc_samp + 1)) + if i1 - i0 < 2: + auc_vals.append(np.nan) + continue + auc_vals.append(float(np.trapz(y[i0:i1], t[i0:i1]))) + + all_times.extend(t[peaks].tolist()) + all_idx.extend(peaks.tolist()) + all_heights.extend(np.asarray(p_heights, float).tolist()) + all_proms.extend(np.asarray(p_proms, float).tolist()) + all_widths_sec.extend(np.asarray(widths_sec, float).tolist()) + all_auc.extend(np.asarray(auc_vals, float).tolist()) + all_file_ids.extend([file_id] * peaks.size) + + if not all_times: + self.statusUpdate.emit("No peaks detected with current settings.", 5000) + self.tbl_signal_metrics.setRowCount(0) + self._refresh_signal_overlay() + return + + peak_times = np.asarray(all_times, float) + peak_idx = np.asarray(all_idx, int) + peak_heights = np.asarray(all_heights, float) + peak_proms = np.asarray(all_proms, float) + peak_widths_sec = np.asarray(all_widths_sec, float) + peak_auc = np.asarray(all_auc, float) + + sort_idx = np.argsort(peak_times) + peak_times = peak_times[sort_idx] + peak_idx = peak_idx[sort_idx] + peak_heights = peak_heights[sort_idx] + peak_proms = peak_proms[sort_idx] + peak_widths_sec = peak_widths_sec[sort_idx] + peak_auc = peak_auc[sort_idx] + all_file_ids = [all_file_ids[i] for i in sort_idx] + + ipi = np.diff(peak_times) if peak_times.size >= 2 else np.array([], float) + freq_per_min = float(peak_times.size) / max((float(np.nanmax(peak_times) - np.nanmin(peak_times)) / 60.0), 1e-12) + metrics = { + "number_of_peaks": float(peak_times.size), + "mean_amplitude": float(np.nanmean(peak_heights)), + "median_amplitude": float(np.nanmedian(peak_heights)), + "amplitude_std": float(np.nanstd(peak_heights)), + "mean_prominence": float(np.nanmean(peak_proms)), + "mean_width_half_prom_s": float(np.nanmean(peak_widths_sec)) if peak_widths_sec.size else np.nan, + "peak_frequency_per_min": freq_per_min, + "mean_inter_peak_interval_s": float(np.nanmean(ipi)) if ipi.size else np.nan, + "mean_auc": float(np.nanmean(peak_auc)) if np.any(np.isfinite(peak_auc)) else np.nan, + } + + self.last_signal_events = { + "peak_times_sec": peak_times, + "peak_indices": peak_idx, + "peak_heights": peak_heights, + "peak_prominences": peak_proms, + "peak_widths_sec": peak_widths_sec, + "peak_auc": peak_auc, + "file_ids": all_file_ids, + "derived_metrics": metrics, + "params": { + "method": self.combo_signal_method.currentText(), + "prominence": float(self.spin_peak_prominence.value()), + "min_height": float(self.spin_peak_height.value()), + "min_distance_sec": float(self.spin_peak_distance.value()), + "smooth_sigma_sec": float(self.spin_peak_smooth.value()), + "baseline_mode": self.combo_peak_baseline.currentText(), + "baseline_window_sec": float(self.spin_peak_baseline_window.value()), + "rate_bin_sec": float(self.spin_peak_rate_bin.value()), + "auc_half_window_sec": float(self.spin_peak_auc_window.value()), + }, + } + + self.statusUpdate.emit(f"Detected {peak_times.size} peak(s).", 5000) + self._refresh_signal_overlay() + self._render_signal_event_plots() + self._update_signal_metrics_table() + if not self._autosave_restoring: + self._project_dirty = True + self._save_settings() + self._update_status_strip() + + def _render_signal_event_plots(self) -> None: + for pw in (self.plot_peak_amp, self.plot_peak_ibi, self.plot_peak_rate): + pw.clear() + if not self.last_signal_events: + return + peak_times = np.asarray(self.last_signal_events.get("peak_times_sec", np.array([], float)), float) + peak_heights = np.asarray(self.last_signal_events.get("peak_heights", np.array([], float)), float) + if peak_times.size == 0 or peak_heights.size == 0: + return + + def _bar_hist(plot: pg.PlotWidget, values: np.ndarray, color: Tuple[int, int, int]) -> None: + vals = np.asarray(values, float) + vals = vals[np.isfinite(vals)] + if vals.size == 0: + return + bins = min(40, max(8, int(np.sqrt(vals.size)))) + hist, edges = np.histogram(vals, bins=bins) + bars = pg.BarGraphItem(x=edges[:-1], height=hist, width=np.diff(edges), brush=color) + plot.addItem(bars) + + _bar_hist(self.plot_peak_amp, peak_heights, (240, 120, 80)) + ipi = np.diff(np.sort(peak_times)) + _bar_hist(self.plot_peak_ibi, ipi, (120, 180, 220)) + + bin_sec = max(0.5, float(self.spin_peak_rate_bin.value())) + t0 = float(np.nanmin(peak_times)) + t1 = float(np.nanmax(peak_times)) + if t1 > t0: + edges = np.arange(t0, t1 + bin_sec, bin_sec) + hist, edges = np.histogram(peak_times, bins=edges) + rate = hist / (bin_sec / 60.0) + bars = pg.BarGraphItem(x=edges[:-1], height=rate, width=np.diff(edges), brush=(180, 180, 120)) + self.plot_peak_rate.addItem(bars) + + def _update_signal_metrics_table(self) -> None: + self.tbl_signal_metrics.setRowCount(0) + if not self.last_signal_events: + return + metrics = self.last_signal_events.get("derived_metrics", {}) or {} + rows = [ + ("number of peaks", metrics.get("number_of_peaks", np.nan)), + ("mean amplitude", metrics.get("mean_amplitude", np.nan)), + ("median amplitude", metrics.get("median_amplitude", np.nan)), + ("amplitude std", metrics.get("amplitude_std", np.nan)), + ("mean prominence", metrics.get("mean_prominence", np.nan)), + ("mean width at half prom (s)", metrics.get("mean_width_half_prom_s", np.nan)), + ("peak frequency (per min)", metrics.get("peak_frequency_per_min", np.nan)), + ("mean inter-peak interval (s)", metrics.get("mean_inter_peak_interval_s", np.nan)), + ("mean AUC", metrics.get("mean_auc", np.nan)), + ] + for key, value in rows: + r = self.tbl_signal_metrics.rowCount() + self.tbl_signal_metrics.insertRow(r) + self.tbl_signal_metrics.setItem(r, 0, QtWidgets.QTableWidgetItem(str(key))) + if isinstance(value, (int, float)) and np.isfinite(value): + txt = f"{float(value):.6g}" + else: + txt = "nan" + self.tbl_signal_metrics.setItem(r, 1, QtWidgets.QTableWidgetItem(txt)) + + def _export_signal_events_csv(self) -> None: + if not self.last_signal_events: + return + start_dir = self._export_start_dir() + out_dir = QtWidgets.QFileDialog.getExistingDirectory(self, "Select export folder", start_dir) + if not out_dir: + return + self._remember_export_dir(out_dir) + peak_times = np.asarray(self.last_signal_events.get("peak_times_sec", np.array([], float)), float) + peak_heights = np.asarray(self.last_signal_events.get("peak_heights", np.array([], float)), float) + peak_proms = np.asarray(self.last_signal_events.get("peak_prominences", np.array([], float)), float) + peak_widths = np.asarray(self.last_signal_events.get("peak_widths_sec", np.array([], float)), float) + file_ids = self.last_signal_events.get("file_ids", []) + if peak_times.size == 0: + return + prefix = "signal_events" + if self._processed: + prefix = os.path.splitext(os.path.basename(self._processed[0].path))[0] + out_path = os.path.join(out_dir, f"{prefix}_peaks.csv") + import csv + with open(out_path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["peak_time_sec", "height", "prominence", "width_sec", "file_id"]) + for i in range(peak_times.size): + fid = file_ids[i] if isinstance(file_ids, list) and i < len(file_ids) else "" + w.writerow( + [ + float(peak_times[i]), + float(peak_heights[i]) if i < peak_heights.size else np.nan, + float(peak_proms[i]) if i < peak_proms.size else np.nan, + float(peak_widths[i]) if i < peak_widths.size else np.nan, + fid, + ] + ) + + def _compute_behavior_analysis(self) -> None: + self.last_behavior_analysis = None + self.tbl_behavior_metrics.setRowCount(0) + self.lbl_behavior_summary.setText("Group metrics: -") + for pw in ( + self.plot_behavior_raster, + self.plot_behavior_rate, + self.plot_behavior_duration, + self.plot_behavior_starts, + ): + pw.clear() + + if not self._behavior_sources: + self.statusUpdate.emit("No behavior files loaded.", 5000) + return + + behavior_name = self.combo_behavior_analysis.currentText().strip() + if not behavior_name: + self.statusUpdate.emit("Select a behavior to analyze.", 5000) return - proc = self._processed[0] - t = proc.time - y = proc.output if proc.output is not None else np.full_like(t, np.nan) + per_file_events: List[Dict[str, object]] = [] + per_file_metrics: List[Dict[str, float]] = [] + all_durations: List[float] = [] + all_starts: List[float] = [] + + if self._processed: + iter_rows: List[Tuple[str, Dict[str, Any], int]] = [] + for idx, proc in enumerate(self._processed): + info = self._match_behavior_source(proc) + if not info: + continue + file_id = os.path.splitext(os.path.basename(proc.path))[0] if proc.path else f"file_{idx + 1}" + iter_rows.append((file_id, info, idx)) + else: + iter_rows = [(stem, info, idx) for idx, (stem, info) in enumerate(self._behavior_sources.items())] - self.curve_trace.setData(t, y, connect="finite", skipFiniteCheck=True) - self._update_behavior_overlay(proc) + for file_id, info, idx in iter_rows: + behaviors = info.get("behaviors") or {} + if behavior_name not in behaviors: + continue + kind = str(info.get("kind", _BEHAVIOR_PARSE_BINARY)) + on, off, dur = self._extract_behavior_events(info, behavior_name) + if on.size == 0: + continue + if kind == _BEHAVIOR_PARSE_BINARY: + t = np.asarray(info.get("time", np.array([], float)), float) + session_dur = float(t[-1] - t[0]) if t.size > 1 else 0.0 + total_time = float(np.nansum(dur)) + frac_time = total_time / session_dur if session_dur > 0 else np.nan + else: + session_dur = np.nan + if self._processed and 0 <= idx < len(self._processed): + t_proc = np.asarray(self._processed[idx].time, float) if self._processed[idx].time is not None else np.array([], float) + if t_proc.size > 1: + session_dur = float(t_proc[-1] - t_proc[0]) + if (not np.isfinite(session_dur) or session_dur <= 0) and on.size > 1: + session_dur = float(np.nanmax(on) - np.nanmin(on)) + total_time = np.nan + frac_time = np.nan + rate_per_min = float(on.size) / (session_dur / 60.0) if np.isfinite(session_dur) and session_dur > 0 else np.nan + finite_dur = dur[np.isfinite(dur)] + + metric_row = { + "file_id": file_id, + "event_count": float(on.size), + "total_time": total_time, + "mean_duration": float(np.nanmean(finite_dur)) if finite_dur.size else np.nan, + "median_duration": float(np.nanmedian(finite_dur)) if finite_dur.size else np.nan, + "std_duration": float(np.nanstd(finite_dur)) if finite_dur.size else np.nan, + "rate_per_min": rate_per_min, + "fraction_time": frac_time, + } + per_file_metrics.append(metric_row) + all_durations.extend(np.asarray(dur, float).tolist()) + all_starts.extend(np.asarray(on, float).tolist()) + per_file_events.append( + { + "file_id": file_id, + "start_sec": np.asarray(on, float), + "end_sec": np.asarray(off, float), + "duration_sec": np.asarray(dur, float), + "row_index": idx + 1, + } + ) + + if not per_file_metrics: + self.statusUpdate.emit("No behavior events found.", 5000) + return - # draw event lines if possible - for ln in self.event_lines: - self.plot_trace.removeItem(ln) - self.event_lines = [] - for lab in self._event_labels: - self.plot_trace.removeItem(lab) - self._event_labels = [] - for reg in self._event_regions: - self.plot_trace.removeItem(reg) - self._event_regions = [] + def _mstd(key: str) -> Tuple[float, float]: + vals = np.asarray([r.get(key, np.nan) for r in per_file_metrics], float) + vals = vals[np.isfinite(vals)] + if vals.size == 0: + return np.nan, np.nan + return float(np.nanmean(vals)), float(np.nanstd(vals)) + + gm_count, gs_count = _mstd("event_count") + gm_total, gs_total = _mstd("total_time") + gm_mean, gs_mean = _mstd("mean_duration") + gm_rate, gs_rate = _mstd("rate_per_min") + gm_frac, gs_frac = _mstd("fraction_time") + group_metrics = { + "event_count_mean": gm_count, + "event_count_std": gs_count, + "total_time_mean": gm_total, + "total_time_std": gs_total, + "mean_duration_mean": gm_mean, + "mean_duration_std": gs_mean, + "rate_per_min_mean": gm_rate, + "rate_per_min_std": gs_rate, + "fraction_time_mean": gm_frac, + "fraction_time_std": gs_frac, + } - ev, dur = self._get_events_for_proc(proc) - ev, dur = self._filter_events(ev, dur) - if ev.size: - # limit lines to a reasonable amount for UI - ev = ev[:200] - max_y = float(np.nanmax(y)) if np.isfinite(np.nanmax(y)) else 0.0 - for i, et in enumerate(ev, start=1): - ln = pg.InfiniteLine(pos=float(et), angle=90, pen=pg.mkPen((220, 220, 220), width=1.0, style=QtCore.Qt.PenStyle.DashLine)) - self.plot_trace.addItem(ln) - self.event_lines.append(ln) - label = pg.TextItem(str(i), color=(200, 200, 200)) - label.setPos(float(et), max_y) - self.plot_trace.addItem(label) - self._event_labels.append(label) - if dur is not None and i - 1 < dur.size and np.isfinite(dur[i - 1]): - t0 = float(et) - t1 = float(et + dur[i - 1]) - reg = pg.LinearRegionItem(values=(t0, t1), brush=(200, 200, 200, 40), movable=False) - self.plot_trace.addItem(reg) - self._event_regions.append(reg) + self.last_behavior_analysis = { + "behavior_name": behavior_name, + "per_file_events": per_file_events, + "per_file_metrics": per_file_metrics, + "group_metrics": group_metrics, + "params": { + "bin_sec": float(self.spin_behavior_bin.value()), + "aligned_view": bool(self.cb_behavior_aligned.isChecked()), + }, + } + self.statusUpdate.emit(f"Analyzed {len(per_file_metrics)} file(s).", 5000) + self._render_behavior_analysis_outputs() + if not self._autosave_restoring: + self._project_dirty = True + self._save_settings() - def _update_behavior_overlay(self, proc: ProcessedTrial) -> None: - if not proc or proc.time is None: - self.curve_behavior.setData([], []) + def _render_behavior_analysis_outputs(self) -> None: + self.tbl_behavior_metrics.setRowCount(0) + if not self.last_behavior_analysis: return - if not self.combo_align.currentText().startswith("Behavior"): - self.curve_behavior.setData([], []) + per_file_metrics = self.last_behavior_analysis.get("per_file_metrics", []) or [] + for row in per_file_metrics: + r = self.tbl_behavior_metrics.rowCount() + self.tbl_behavior_metrics.insertRow(r) + values = [ + row.get("file_id", ""), + row.get("event_count", np.nan), + row.get("total_time", np.nan), + row.get("mean_duration", np.nan), + row.get("median_duration", np.nan), + row.get("std_duration", np.nan), + row.get("rate_per_min", np.nan), + row.get("fraction_time", np.nan), + ] + for c, v in enumerate(values): + if isinstance(v, (int, float)) and np.isfinite(v): + txt = f"{float(v):.6g}" + else: + txt = str(v) + self.tbl_behavior_metrics.setItem(r, c, QtWidgets.QTableWidgetItem(txt)) + + gm = self.last_behavior_analysis.get("group_metrics", {}) or {} + self.lbl_behavior_summary.setText( + "Group metrics: " + f"count={gm.get('event_count_mean', np.nan):.4g}+-{gm.get('event_count_std', np.nan):.3g} | " + f"rate={gm.get('rate_per_min_mean', np.nan):.4g}+-{gm.get('rate_per_min_std', np.nan):.3g} per min | " + f"frac={gm.get('fraction_time_mean', np.nan):.4g}+-{gm.get('fraction_time_std', np.nan):.3g}" + ) + + for pw in ( + self.plot_behavior_raster, + self.plot_behavior_rate, + self.plot_behavior_duration, + self.plot_behavior_starts, + ): + pw.clear() + + per_file_events = self.last_behavior_analysis.get("per_file_events", []) or [] + all_starts: List[float] = [] + all_durations: List[float] = [] + for ev_row in per_file_events: + starts = np.asarray(ev_row.get("start_sec", np.array([], float)), float) + ends = np.asarray(ev_row.get("end_sec", np.array([], float)), float) + row_idx = float(ev_row.get("row_index", 0)) + if starts.size != ends.size: + continue + all_starts.extend(starts.tolist()) + all_durations.extend(np.asarray(ev_row.get("duration_sec", np.array([], float)), float).tolist()) + for s, e in zip(starts, ends): + s_f = float(s) + e_f = float(e) + if np.isfinite(e_f) and e_f > s_f: + self.plot_behavior_raster.plot([s_f, e_f], [row_idx, row_idx], pen=pg.mkPen((220, 180, 90), width=2.0)) + else: + self.plot_behavior_raster.plot( + [s_f], + [row_idx], + pen=None, + symbol="o", + symbolSize=5, + symbolBrush=(220, 180, 90), + ) + + starts_arr = np.asarray(all_starts, float) + dur_arr = np.asarray(all_durations, float) + starts_arr = starts_arr[np.isfinite(starts_arr)] + dur_arr = dur_arr[np.isfinite(dur_arr)] + + if starts_arr.size: + bin_sec = max(0.5, float(self.spin_behavior_bin.value())) + t0 = float(np.nanmin(starts_arr)) + t1 = float(np.nanmax(starts_arr)) + if t1 > t0: + edges = np.arange(t0, t1 + bin_sec, bin_sec) + hist, edges = np.histogram(starts_arr, bins=edges) + rate = hist / (bin_sec / 60.0) + bars = pg.BarGraphItem(x=edges[:-1], height=rate, width=np.diff(edges), brush=(180, 200, 120)) + self.plot_behavior_rate.addItem(bars) + + bins = min(40, max(8, int(np.sqrt(starts_arr.size)))) + hist, edges = np.histogram(starts_arr, bins=bins) + bars = pg.BarGraphItem(x=edges[:-1], height=hist, width=np.diff(edges), brush=(140, 190, 220)) + self.plot_behavior_starts.addItem(bars) + + if dur_arr.size: + bins = min(40, max(8, int(np.sqrt(dur_arr.size)))) + hist, edges = np.histogram(dur_arr, bins=bins) + bars = pg.BarGraphItem(x=edges[:-1], height=hist, width=np.diff(edges), brush=(220, 150, 110)) + self.plot_behavior_duration.addItem(bars) + + def _export_behavior_metrics_csv(self) -> None: + if not self.last_behavior_analysis: return - info = self._match_behavior_source(proc) - if not info: - self.curve_behavior.setData([], []) + start_dir = self._export_start_dir() + out_dir = QtWidgets.QFileDialog.getExistingDirectory(self, "Select export folder", start_dir) + if not out_dir: return - t = np.asarray(info.get("time", np.array([], float)), float) - behaviors = info.get("behaviors") or {} - beh = self.combo_behavior_name.currentText().strip() - if not beh and behaviors: - beh = next(iter(behaviors.keys())) - if beh not in behaviors or t.size == 0: - self.curve_behavior.setData([], []) + self._remember_export_dir(out_dir) + behavior_name = str(self.last_behavior_analysis.get("behavior_name", "behavior")) + safe_beh = re.sub(r"[^A-Za-z0-9_\\-]+", "_", behavior_name).strip("_") or "behavior" + out_path = os.path.join(out_dir, f"behavior_metrics_{safe_beh}.csv") + import csv + with open(out_path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["file_id", "behavior_name", "event_count", "total_time", "mean_duration", "median_duration", "rate_per_min", "fraction_time"]) + for row in self.last_behavior_analysis.get("per_file_metrics", []) or []: + w.writerow( + [ + row.get("file_id", ""), + behavior_name, + row.get("event_count", np.nan), + row.get("total_time", np.nan), + row.get("mean_duration", np.nan), + row.get("median_duration", np.nan), + row.get("rate_per_min", np.nan), + row.get("fraction_time", np.nan), + ] + ) + + def _export_behavior_events_csv(self) -> None: + if not self.last_behavior_analysis: return - b = np.asarray(behaviors[beh], float) - # Resample to processed time for overlay - t_proc = np.asarray(proc.time, float) - if t_proc.size == 0: - self.curve_behavior.setData([], []) + start_dir = self._export_start_dir() + out_dir = QtWidgets.QFileDialog.getExistingDirectory(self, "Select export folder", start_dir) + if not out_dir: return - b_interp = np.interp(t_proc, t, b) - self.curve_behavior.setData(t_proc, b_interp, connect="finite", skipFiniteCheck=True) + self._remember_export_dir(out_dir) + behavior_name = str(self.last_behavior_analysis.get("behavior_name", "behavior")) + safe_beh = re.sub(r"[^A-Za-z0-9_\\-]+", "_", behavior_name).strip("_") or "behavior" + out_path = os.path.join(out_dir, f"behavior_events_{safe_beh}.csv") + import csv + with open(out_path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["start_sec", "end_sec", "duration_sec", "file_id", "behavior_name"]) + for ev_row in self.last_behavior_analysis.get("per_file_events", []) or []: + file_id = ev_row.get("file_id", "") + starts = np.asarray(ev_row.get("start_sec", np.array([], float)), float) + ends = np.asarray(ev_row.get("end_sec", np.array([], float)), float) + durs = np.asarray(ev_row.get("duration_sec", np.array([], float)), float) + n = min(starts.size, ends.size, durs.size) + for i in range(n): + w.writerow([float(starts[i]), float(ends[i]), float(durs[i]), file_id, behavior_name]) def _compute_psth(self) -> None: + self._queue_settings_save() if not self._processed: - self.lbl_log.setText("No processed data loaded.") + self.statusUpdate.emit("No processed data loaded.", 5000) self._last_global_metrics = None + self._last_events = np.array([], float) + self._last_event_rows = [] if hasattr(self, "lbl_global_metrics"): self.lbl_global_metrics.setText("Global metrics: -") + self._update_status_strip() return # Update trace preview each time (also updates event lines) @@ -1665,6 +5556,8 @@ def _compute_psth(self) -> None: mats: List[np.ndarray] = [] animal_rows: List[np.ndarray] = [] all_dur = [] + all_events: List[float] = [] + event_rows: List[Dict[str, object]] = [] total_events = 0 tvec = None @@ -1673,6 +5566,20 @@ def _compute_psth(self) -> None: ev, dur = self._filter_events(ev, dur) if ev.size == 0: continue + file_id = os.path.splitext(os.path.basename(proc.path))[0] if proc.path else "import" + all_events.extend(np.asarray(ev, float).tolist()) + if dur is None or len(dur) != ev.size: + dur_row = np.full(ev.shape, np.nan, dtype=float) + else: + dur_row = np.asarray(dur, float) + for i in range(ev.size): + event_rows.append( + { + "file_id": file_id, + "event_time_sec": float(ev[i]), + "duration_sec": float(dur_row[i]) if i < dur_row.size else np.nan, + } + ) tvec, mat = _compute_psth_matrix(proc.time, proc.output, ev, window, baseline, res_hz, smooth_sigma_s=smooth) if mat.size == 0: continue @@ -1688,13 +5595,21 @@ def _compute_psth(self) -> None: self._render_global_metrics() if not mats or tvec is None: - self.lbl_log.setText("No events found for the current alignment.") + self.statusUpdate.emit("No events found for the current alignment.", 5000) + self._last_events = np.array([], float) + self._last_event_rows = [] + self._last_durations = np.array([], float) + self._update_status_strip() return mat_events = np.vstack(mats) if group_mode: if not animal_rows: self.lbl_log.setText("No events found for the current alignment.") + self._last_events = np.array([], float) + self._last_event_rows = [] + self._last_durations = np.array([], float) + self._update_status_strip() return mat_display = np.vstack(animal_rows) else: @@ -1707,20 +5622,24 @@ def _compute_psth(self) -> None: self._render_metrics(mat_display, tvec) self._last_mat = mat_display self._last_tvec = tvec - self._last_events = None + self._last_events = np.asarray(all_events, float) if all_events else np.array([], float) self._last_durations = dur_all + self._last_event_rows = event_rows if group_mode: - self.lbl_log.setText(f"Computed PSTH for {total_events} event(s) across {mat_display.shape[0]} animal(s).") + self.statusUpdate.emit(f"Computed PSTH for {total_events} event(s) across {mat_display.shape[0]} animal(s).", 5000) else: - self.lbl_log.setText(f"Computed PSTH for {total_events} event(s).") + self.statusUpdate.emit(f"Computed PSTH for {total_events} event(s).", 5000) self._update_metric_regions() + self._update_status_strip() self._save_settings() except Exception as e: - self.lbl_log.setText(f"Post-processing error: {e}") + self.statusUpdate.emit(f"Postprocessing error: {e}", 5000) + self._update_status_strip() def _render_heatmap(self, mat: np.ndarray, tvec: np.ndarray) -> None: if mat.size == 0: self.img.setImage(np.zeros((1, 1))) + self.heat_zero_line.setVisible(False) return # ImageItem maps axis-0 -> x and axis-1 -> y; transpose so time is x, trials are y. @@ -1730,6 +5649,8 @@ def _render_heatmap(self, mat: np.ndarray, tvec: np.ndarray) -> None: cmap = pg.colormap.get(cmap_name) lut = cmap.getLookupTable() self.img.setLookupTable(lut) + if hasattr(self, "heat_lut") and getattr(self.heat_lut, "item", None) is not None: + self.heat_lut.item.gradient.setColorMap(cmap) except Exception: pass hmin = self._style.get("heatmap_min", None) @@ -1747,6 +5668,8 @@ def _render_heatmap(self, mat: np.ndarray, tvec: np.ndarray) -> None: self.img.setRect(QtCore.QRectF(x0, 0, x1 - x0, img.shape[1])) self.plot_heat.setXRange(x0, x1, padding=0) self.plot_heat.setYRange(0, float(img.shape[1]), padding=0) + self.heat_zero_line.setPos(0.0) + self.heat_zero_line.setVisible(bool(x0 <= 0.0 <= x1)) def _render_duration_hist(self, durations: np.ndarray) -> None: self.plot_dur.clear() @@ -1790,10 +5713,49 @@ def _render_avg(self, mat: np.ndarray, tvec: np.ndarray) -> None: self.curve_sem_lo.setData(tvec, avg - sem, connect="finite", skipFiniteCheck=True) self.plot_avg.setXRange(float(tvec[0]), float(tvec[-1]), padding=0) + @staticmethod + def _finite_mean_sem(values: np.ndarray) -> Tuple[float, float, int]: + vals = np.asarray(values, float) + vals = vals[np.isfinite(vals)] + n = int(vals.size) + if n == 0: + return 0.0, 0.0, 0 + mean = float(np.nanmean(vals)) + if n < 2: + return mean, 0.0, n + sem = float(np.nanstd(vals, ddof=1) / np.sqrt(float(n))) + if not np.isfinite(sem): + sem = 0.0 + return mean, sem, n + + @staticmethod + def _jittered_x(center: float, n: int, half_width: float = 0.16) -> np.ndarray: + if n <= 0: + return np.array([], float) + if n == 1: + return np.array([float(center)], float) + return float(center) + np.linspace(-abs(float(half_width)), abs(float(half_width)), n) + + @staticmethod + def _set_error_bar(item: pg.ErrorBarItem, x: float, y: float, err: float) -> None: + yy = float(y) if np.isfinite(y) else 0.0 + ee = float(err) if np.isfinite(err) and err > 0 else 0.0 + item.setData( + x=np.array([float(x)], float), + y=np.array([yy], float), + top=np.array([ee], float), + bottom=np.array([ee], float), + ) + def _render_metrics(self, mat: np.ndarray, tvec: np.ndarray) -> None: if mat.size == 0 or not self.cb_metrics.isChecked(): self.metrics_bar_pre.setOpts(height=[0]) self.metrics_bar_post.setOpts(height=[0]) + self.metrics_pairs_curve.setData([], []) + self.metrics_scatter_pre.setData([], []) + self.metrics_scatter_post.setData([], []) + self._set_error_bar(self.metrics_err_pre, 0.0, 0.0, 0.0) + self._set_error_bar(self.metrics_err_post, 1.0, 0.0, 0.0) self._last_metrics = None return metric = self.combo_metric.currentText() @@ -1813,34 +5775,109 @@ def _window_vals(a: float, b: float) -> np.ndarray: if pre.size == 0 or post.size == 0: self.metrics_bar_pre.setOpts(height=[0]) self.metrics_bar_post.setOpts(height=[0]) + self.metrics_pairs_curve.setData([], []) + self.metrics_scatter_pre.setData([], []) + self.metrics_scatter_post.setData([], []) + self._set_error_bar(self.metrics_err_pre, 0.0, 0.0, 0.0) + self._set_error_bar(self.metrics_err_post, 1.0, 0.0, 0.0) self._last_metrics = None return def _metric_vals(win: np.ndarray, duration: float) -> np.ndarray: if win.size == 0: return np.array([], float) + w = np.asarray(win, float) + valid = np.isfinite(w) + counts = np.sum(valid, axis=1) + sums = np.nansum(w, axis=1) + vals = np.divide(sums, counts, out=np.full(w.shape[0], np.nan, dtype=float), where=counts > 0) if metric.startswith("AUC"): - with np.errstate(all="ignore"): - vals = np.nanmean(win, axis=1) * float(abs(duration)) - else: - with np.errstate(all="ignore"): - vals = np.nanmean(win, axis=1) - vals = np.asarray(vals, float) - vals = vals[np.isfinite(vals)] - return vals - - pre_vals = _metric_vals(pre, pre1 - pre0) - post_vals = _metric_vals(post, post1 - post0) - pre_mean = float(np.nanmean(pre_vals)) if pre_vals.size else 0.0 - post_mean = float(np.nanmean(post_vals)) if post_vals.size else 0.0 + vals = vals * float(abs(duration)) + return np.asarray(vals, float) + + pre_vals_all = _metric_vals(pre, pre1 - pre0) + post_vals_all = _metric_vals(post, post1 - post0) + pre_vals_finite = pre_vals_all[np.isfinite(pre_vals_all)] + post_vals_finite = post_vals_all[np.isfinite(post_vals_all)] + pre_mean, pre_sem, pre_n = self._finite_mean_sem(pre_vals_finite) + post_mean, post_sem, post_n = self._finite_mean_sem(post_vals_finite) self.metrics_bar_pre.setOpts(height=[pre_mean]) self.metrics_bar_post.setOpts(height=[post_mean]) - ymin = min(pre_mean, post_mean, 0.0) - ymax = max(pre_mean, post_mean, 0.0) + group_mode = self.tab_sources.currentIndex() == 1 + + pair_mask = np.isfinite(pre_vals_all) & np.isfinite(post_vals_all) + pre_pair = pre_vals_all[pair_mask] + post_pair = post_vals_all[pair_mask] + if pre_pair.size and post_pair.size: + n_pair = int(min(pre_pair.size, post_pair.size)) + pre_pair = pre_pair[:n_pair] + post_pair = post_pair[:n_pair] + # Build segmented polyline: (0, pre_i) -> (1, post_i), NaN separator. + x_line = np.empty(n_pair * 3, dtype=float) + y_line = np.empty(n_pair * 3, dtype=float) + x_line[0::3] = 0.0 + x_line[1::3] = 1.0 + x_line[2::3] = np.nan + y_line[0::3] = pre_pair + y_line[1::3] = post_pair + y_line[2::3] = np.nan + self.metrics_pairs_curve.setData(x_line, y_line, connect="finite", skipFiniteCheck=True) + if group_mode: + x_pre = self._jittered_x(0.0, n_pair, half_width=0.16) + x_post = self._jittered_x(1.0, n_pair, half_width=0.16) + else: + x_pre = np.zeros(n_pair, dtype=float) + x_post = np.ones(n_pair, dtype=float) + self.metrics_scatter_pre.setData(x_pre, pre_pair) + self.metrics_scatter_post.setData(x_post, post_pair) + else: + self.metrics_pairs_curve.setData([], []) + self.metrics_scatter_pre.setData([], []) + self.metrics_scatter_post.setData([], []) + + if group_mode: + self._set_error_bar(self.metrics_err_pre, 0.0, pre_mean, pre_sem) + self._set_error_bar(self.metrics_err_post, 1.0, post_mean, post_sem) + else: + self._set_error_bar(self.metrics_err_pre, 0.0, 0.0, 0.0) + self._set_error_bar(self.metrics_err_post, 1.0, 0.0, 0.0) + + finite_all = np.concatenate( + [ + pre_vals_finite if pre_vals_finite.size else np.array([], float), + post_vals_finite if post_vals_finite.size else np.array([], float), + np.array( + [ + pre_mean, + post_mean, + pre_mean - pre_sem, + pre_mean + pre_sem, + post_mean - post_sem, + post_mean + post_sem, + 0.0, + ], + float, + ), + ] + ) + finite_all = finite_all[np.isfinite(finite_all)] + if finite_all.size: + ymin = float(np.nanmin(finite_all)) + ymax = float(np.nanmax(finite_all)) + else: + ymin, ymax = 0.0, 1.0 if ymin == ymax: ymax = ymin + 1.0 self.plot_metrics.setYRange(ymin, ymax, padding=0.2) - self._last_metrics = {"pre": pre_mean, "post": post_mean, "metric": metric} + self._last_metrics = { + "pre": pre_mean, + "post": post_mean, + "pre_sem": pre_sem, + "post_sem": post_sem, + "pre_n": float(pre_n), + "post_n": float(post_n), + "metric": metric, + } def _compute_global_metrics_for_trace( self, @@ -1899,6 +5936,10 @@ def _render_global_metrics(self) -> None: self.lbl_global_metrics.setText("Global metrics: -") self.global_bar_amp.setOpts(height=[0]) self.global_bar_freq.setOpts(height=[0]) + self.global_scatter_amp.setData([], []) + self.global_scatter_freq.setData([], []) + self._set_error_bar(self.global_err_amp, 0.0, 0.0, 0.0) + self._set_error_bar(self.global_err_freq, 1.0, 0.0, 0.0) return if not (self.cb_global_amp.isChecked() or self.cb_global_freq.isChecked()): @@ -1906,6 +5947,10 @@ def _render_global_metrics(self) -> None: self.lbl_global_metrics.setText("Global metrics: -") self.global_bar_amp.setOpts(height=[0]) self.global_bar_freq.setOpts(height=[0]) + self.global_scatter_amp.setData([], []) + self.global_scatter_freq.setData([], []) + self._set_error_bar(self.global_err_amp, 0.0, 0.0, 0.0) + self._set_error_bar(self.global_err_freq, 1.0, 0.0, 0.0) return start_s = float(self.spin_global_start.value()) @@ -1929,22 +5974,35 @@ def _render_global_metrics(self) -> None: durations.append(res["duration"]) thrs.append(res["thr"]) - if not amps and not freqs: + amp_vals = np.asarray(amps, float) + freq_vals = np.asarray(freqs, float) + amp_vals = amp_vals[np.isfinite(amp_vals)] + freq_vals = freq_vals[np.isfinite(freq_vals)] + if amp_vals.size == 0 and freq_vals.size == 0: self._last_global_metrics = None self.lbl_global_metrics.setText("Global metrics: -") self.global_bar_amp.setOpts(height=[0]) self.global_bar_freq.setOpts(height=[0]) + self.global_scatter_amp.setData([], []) + self.global_scatter_freq.setData([], []) + self._set_error_bar(self.global_err_amp, 0.0, 0.0, 0.0) + self._set_error_bar(self.global_err_freq, 1.0, 0.0, 0.0) return - avg_amp = float(np.nanmean(amps)) if amps else 0.0 - avg_freq = float(np.nanmean(freqs)) if freqs else 0.0 + avg_amp, sem_amp, n_amp = self._finite_mean_sem(amp_vals) + avg_freq, sem_freq, n_freq = self._finite_mean_sem(freq_vals) total_peaks = float(np.nansum(peaks)) if peaks else 0.0 avg_thr = float(np.nanmean(thrs)) if thrs else 0.0 avg_dur = float(np.nanmean(durations)) if durations else 0.0 + group_mode = self.tab_sources.currentIndex() == 1 self._last_global_metrics = { "amp": avg_amp, + "amp_sem": sem_amp, + "amp_n": float(n_amp), "freq": avg_freq, + "freq_sem": sem_freq, + "freq_n": float(n_freq), "peaks": total_peaks, "thr": avg_thr, "duration": avg_dur, @@ -1952,53 +6010,1002 @@ def _render_global_metrics(self) -> None: "end": end_s, } - parts = [] - if self.cb_global_amp.isChecked(): - parts.append(f"amp={avg_amp:.4g}") - if self.cb_global_freq.isChecked(): - parts.append(f"freq={avg_freq:.4g} Hz") - parts.append(f"peaks={int(total_peaks)}") - self.lbl_global_metrics.setText("Global metrics: " + " | ".join(parts)) + parts = [] + if self.cb_global_amp.isChecked(): + if group_mode: + parts.append(f"amp={avg_amp:.4g}+-{sem_amp:.3g}") + else: + parts.append(f"amp={avg_amp:.4g}") + if self.cb_global_freq.isChecked(): + if group_mode: + parts.append(f"freq={avg_freq:.4g}+-{sem_freq:.3g} Hz") + else: + parts.append(f"freq={avg_freq:.4g} Hz") + parts.append(f"peaks={int(total_peaks)}") + self.lbl_global_metrics.setText("Global metrics: " + " | ".join(parts)) + + self.global_bar_amp.setOpts(height=[avg_amp if self.cb_global_amp.isChecked() else 0.0]) + self.global_bar_freq.setOpts(height=[avg_freq if self.cb_global_freq.isChecked() else 0.0]) + if group_mode: + if self.cb_global_amp.isChecked() and amp_vals.size: + self.global_scatter_amp.setData(self._jittered_x(0.0, int(amp_vals.size), half_width=0.16), amp_vals) + self._set_error_bar(self.global_err_amp, 0.0, avg_amp, sem_amp) + else: + self.global_scatter_amp.setData([], []) + self._set_error_bar(self.global_err_amp, 0.0, 0.0, 0.0) + if self.cb_global_freq.isChecked() and freq_vals.size: + self.global_scatter_freq.setData(self._jittered_x(1.0, int(freq_vals.size), half_width=0.16), freq_vals) + self._set_error_bar(self.global_err_freq, 1.0, avg_freq, sem_freq) + else: + self.global_scatter_freq.setData([], []) + self._set_error_bar(self.global_err_freq, 1.0, 0.0, 0.0) + else: + self.global_scatter_amp.setData([], []) + self.global_scatter_freq.setData([], []) + self._set_error_bar(self.global_err_amp, 0.0, 0.0, 0.0) + self._set_error_bar(self.global_err_freq, 1.0, 0.0, 0.0) + y_candidates: List[float] = [0.0] + if self.cb_global_amp.isChecked(): + y_candidates.extend([avg_amp, avg_amp - sem_amp, avg_amp + sem_amp]) + if group_mode and amp_vals.size: + y_candidates.extend(np.asarray(amp_vals, float).tolist()) + if self.cb_global_freq.isChecked(): + y_candidates.extend([avg_freq, avg_freq - sem_freq, avg_freq + sem_freq]) + if group_mode and freq_vals.size: + y_candidates.extend(np.asarray(freq_vals, float).tolist()) + y_arr = np.asarray(y_candidates, float) + y_arr = y_arr[np.isfinite(y_arr)] + if y_arr.size: + ymin = float(np.nanmin(y_arr)) + ymax = float(np.nanmax(y_arr)) + else: + ymin, ymax = 0.0, 1.0 + if ymin == ymax: + ymax = ymin + 1.0 + self.plot_global.setYRange(ymin, ymax, padding=0.2) + + def _update_metric_regions(self) -> None: + if self._pre_region is not None: + self.plot_avg.removeItem(self._pre_region) + if self._post_region is not None: + self.plot_avg.removeItem(self._post_region) + + if not self.cb_metrics.isChecked(): + self._pre_region = None + self._post_region = None + return + + pre0 = float(self.spin_metric_pre0.value()) + pre1 = float(self.spin_metric_pre1.value()) + post0 = float(self.spin_metric_post0.value()) + post1 = float(self.spin_metric_post1.value()) + + self._pre_region = pg.LinearRegionItem(values=(pre0, pre1), brush=(90, 143, 214, 60), movable=False) + self._post_region = pg.LinearRegionItem(values=(post0, post1), brush=(214, 122, 90, 60), movable=False) + self.plot_avg.addItem(self._pre_region) + self.plot_avg.addItem(self._post_region) + + def _style_color_tuple(self, key: str, fallback: Tuple[int, ...]) -> Tuple[int, ...]: + raw = self._style.get(key, fallback) + if isinstance(raw, np.ndarray): + vals = raw.tolist() + elif isinstance(raw, (list, tuple)): + vals = list(raw) + else: + vals = list(fallback) + out: List[int] = [] + for i, default in enumerate(list(fallback)): + try: + v = int(vals[i]) if i < len(vals) else int(default) + except Exception: + v = int(default) + out.append(max(0, min(255, v))) + return tuple(out) + + def _apply_plot_style(self) -> None: + self.curve_trace.setPen(pg.mkPen(self._style_color_tuple("trace", (90, 190, 255)), width=1.1)) + self.curve_behavior.setPen(pg.mkPen(self._style_color_tuple("behavior", (220, 180, 80)), width=1.0)) + self.curve_avg.setPen(pg.mkPen(self._style_color_tuple("avg", (90, 190, 255)), width=1.3)) + sem_edge = self._style_color_tuple("sem_edge", (152, 201, 143)) + sem_fill = self._style_color_tuple("sem_fill", (188, 230, 178, 96)) + self.curve_sem_hi.setPen(pg.mkPen(sem_edge, width=1.0)) + self.curve_sem_lo.setPen(pg.mkPen(sem_edge, width=1.0)) + if hasattr(self, "sem_band"): + self.sem_band.setBrush(pg.mkBrush(*sem_fill)) + bg = self._style_color_tuple("plot_bg", (36, 42, 52)) + grid_enabled = bool(self._style.get("grid_enabled", True)) + try: + grid_alpha = float(self._style.get("grid_alpha", 0.25)) + except Exception: + grid_alpha = 0.25 + grid_alpha = max(0.0, min(1.0, grid_alpha)) + for pw in ( + self.plot_trace, + self.plot_heat, + self.plot_dur, + self.plot_avg, + self.plot_metrics, + self.plot_global, + self.plot_peak_amp, + self.plot_peak_ibi, + self.plot_peak_rate, + self.plot_behavior_raster, + self.plot_behavior_rate, + self.plot_behavior_duration, + self.plot_behavior_starts, + self.plot_spatial_occupancy, + self.plot_spatial_activity, + self.plot_spatial_velocity, + ): + try: + pw.setBackground(QtGui.QColor(*bg[:3])) + except Exception: + pass + try: + pw.showGrid(x=grid_enabled, y=grid_enabled, alpha=grid_alpha) + except Exception: + pass + cmap_name = str(self._style.get("heatmap_cmap", "viridis")) + try: + cmap = pg.colormap.get(cmap_name) + if hasattr(self, "heat_lut") and getattr(self.heat_lut, "item", None) is not None: + self.heat_lut.item.gradient.setColorMap(cmap) + except Exception: + pass + + def _on_heatmap_levels_changed(self) -> None: + if self._is_restoring_settings: + return + if not hasattr(self, "heat_lut") or getattr(self.heat_lut, "item", None) is None: + return + try: + levels = self.heat_lut.item.getLevels() + except Exception: + return + if not isinstance(levels, (list, tuple)) or len(levels) < 2: + return + lo = float(levels[0]) + hi = float(levels[1]) + if np.isfinite(lo) and np.isfinite(hi) and hi > lo: + self._style["heatmap_min"] = lo + self._style["heatmap_max"] = hi + self._queue_settings_save() + + def _open_style_dialog(self) -> None: + dlg = StyleDialog(self._style, self) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + self._style = dlg.get_style() + self._apply_plot_style() + self._render_heatmap(self._last_mat if self._last_mat is not None else np.zeros((1, 1)), self._last_tvec if self._last_tvec is not None else np.array([0.0, 1.0])) + self._render_spatial_heatmap( + self._last_spatial_occupancy_map, + self._last_spatial_activity_map, + self._last_spatial_velocity_map, + self._last_spatial_extent, + self.combo_spatial_x.currentText().strip(), + self.combo_spatial_y.currentText().strip(), + "Spatial occupancy", + "Spatial activity", + "Spatial velocity", + ) + self._save_settings() + + def _h5_text(self, value: object, default: str = "") -> str: + if value is None: + return default + if isinstance(value, bytes): + try: + return value.decode("utf-8", errors="ignore") + except Exception: + return default + return str(value) + + def _write_h5_json(self, group: h5py.Group, name: str, payload: Dict[str, object]) -> None: + text = json.dumps(payload or {}) + dtype = h5py.string_dtype(encoding="utf-8") + if name in group: + del group[name] + group.create_dataset(name, data=text, dtype=dtype) + + def _write_h5_json_any(self, group: h5py.Group, name: str, payload: object) -> None: + text = json.dumps(payload) + dtype = h5py.string_dtype(encoding="utf-8") + if name in group: + del group[name] + group.create_dataset(name, data=text, dtype=dtype) + + def _read_h5_json(self, group: Optional[h5py.Group], name: str) -> Dict[str, object]: + if group is None or name not in group: + return {} + try: + raw = group[name][()] + except Exception: + return {} + if isinstance(raw, np.ndarray): + try: + raw = raw.item() + except Exception: + raw = raw.tolist() + if isinstance(raw, bytes): + try: + raw = raw.decode("utf-8", errors="ignore") + except Exception: + raw = "" + text = str(raw or "") + if not text: + return {} + try: + data = json.loads(text) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + def _read_h5_json_any(self, group: Optional[h5py.Group], name: str, default: object) -> object: + if group is None or name not in group: + return default + try: + raw = group[name][()] + except Exception: + return default + if isinstance(raw, np.ndarray): + try: + raw = raw.item() + except Exception: + raw = raw.tolist() + if isinstance(raw, bytes): + try: + raw = raw.decode("utf-8", errors="ignore") + except Exception: + raw = "" + text = str(raw or "") + if not text: + return default + try: + return json.loads(text) + except Exception: + return default + + def _write_h5_str_list(self, group: h5py.Group, name: str, values: List[str]) -> None: + dtype = h5py.string_dtype(encoding="utf-8") + arr = np.asarray([str(v) for v in (values or [])], dtype=dtype) + if name in group: + del group[name] + group.create_dataset(name, data=arr, dtype=dtype) + + def _read_h5_str_list(self, group: Optional[h5py.Group], name: str) -> List[str]: + if group is None or name not in group: + return [] + try: + raw = group[name][()] + except Exception: + return [] + if isinstance(raw, bytes): + return [raw.decode("utf-8", errors="ignore")] + if isinstance(raw, str): + return [raw] + if isinstance(raw, np.ndarray): + out: List[str] = [] + for item in raw.tolist(): + if isinstance(item, bytes): + out.append(item.decode("utf-8", errors="ignore")) + else: + out.append(str(item)) + return out + return [] + + def _write_h5_numeric(self, group: h5py.Group, name: str, values: np.ndarray) -> None: + arr = np.asarray(values, float) + if name in group: + del group[name] + kwargs: Dict[str, object] = {} + if arr.size > 0: + kwargs["compression"] = "gzip" + group.create_dataset(name, data=arr, **kwargs) + + def _read_h5_numeric(self, group: h5py.Group, name: str) -> Optional[np.ndarray]: + if name not in group: + return None + try: + return np.asarray(group[name][()], float) + except Exception: + return None + + def _save_signal_events_h5(self, parent: h5py.Group) -> None: + if not isinstance(self.last_signal_events, dict) or not self.last_signal_events: + return + group = parent.create_group("signal_events") + for key in ( + "peak_times_sec", + "peak_indices", + "peak_heights", + "peak_prominences", + "peak_widths_sec", + "peak_auc", + ): + self._write_h5_numeric(group, key, np.asarray(self.last_signal_events.get(key, np.array([], float)), float)) + self._write_h5_str_list(group, "file_ids", [str(v) for v in self.last_signal_events.get("file_ids", []) or []]) + self._write_h5_json(group, "derived_metrics_json", dict(self.last_signal_events.get("derived_metrics", {}) or {})) + self._write_h5_json(group, "params_json", dict(self.last_signal_events.get("params", {}) or {})) + + def _load_signal_events_h5(self, parent: Optional[h5py.Group]) -> Optional[Dict[str, object]]: + if parent is None: + return None + group = parent.get("signal_events") + if not isinstance(group, h5py.Group): + return None + + def _num(name: str) -> np.ndarray: + arr = self._read_h5_numeric(group, name) + if arr is None: + return np.array([], float) + return np.asarray(arr, float) + + out: Dict[str, object] = { + "peak_times_sec": _num("peak_times_sec"), + "peak_indices": _num("peak_indices"), + "peak_heights": _num("peak_heights"), + "peak_prominences": _num("peak_prominences"), + "peak_widths_sec": _num("peak_widths_sec"), + "peak_auc": _num("peak_auc"), + "file_ids": self._read_h5_str_list(group, "file_ids"), + "derived_metrics": self._read_h5_json(group, "derived_metrics_json"), + "params": self._read_h5_json(group, "params_json"), + } + return out + + def _save_behavior_analysis_h5(self, parent: h5py.Group) -> None: + if not isinstance(self.last_behavior_analysis, dict) or not self.last_behavior_analysis: + return + group = parent.create_group("behavior_analysis") + group.attrs["behavior_name"] = str(self.last_behavior_analysis.get("behavior_name", "") or "") + self._write_h5_json_any(group, "per_file_metrics_json", self.last_behavior_analysis.get("per_file_metrics", []) or []) + self._write_h5_json(group, "group_metrics_json", dict(self.last_behavior_analysis.get("group_metrics", {}) or {})) + self._write_h5_json(group, "params_json", dict(self.last_behavior_analysis.get("params", {}) or {})) + + events_group = group.create_group("per_file_events") + for idx, row in enumerate(self.last_behavior_analysis.get("per_file_events", []) or []): + if not isinstance(row, dict): + continue + entry = events_group.create_group(f"item_{idx:04d}") + entry.attrs["file_id"] = str(row.get("file_id", "") or "") + try: + entry.attrs["row_index"] = int(row.get("row_index", idx + 1)) + except Exception: + entry.attrs["row_index"] = int(idx + 1) + self._write_h5_numeric(entry, "start_sec", np.asarray(row.get("start_sec", np.array([], float)), float)) + self._write_h5_numeric(entry, "end_sec", np.asarray(row.get("end_sec", np.array([], float)), float)) + self._write_h5_numeric(entry, "duration_sec", np.asarray(row.get("duration_sec", np.array([], float)), float)) + + def _load_behavior_analysis_h5(self, parent: Optional[h5py.Group]) -> Optional[Dict[str, object]]: + if parent is None: + return None + group = parent.get("behavior_analysis") + if not isinstance(group, h5py.Group): + return None + + per_file_metrics_raw = self._read_h5_json_any(group, "per_file_metrics_json", []) + per_file_metrics = per_file_metrics_raw if isinstance(per_file_metrics_raw, list) else [] + + per_file_events: List[Dict[str, object]] = [] + events_group = group.get("per_file_events") + if isinstance(events_group, h5py.Group): + for key in sorted(events_group.keys()): + entry = events_group.get(key) + if not isinstance(entry, h5py.Group): + continue + try: + row_index = int(entry.attrs.get("row_index", 0)) + except Exception: + row_index = 0 + per_file_events.append( + { + "file_id": self._h5_text(entry.attrs.get("file_id", ""), ""), + "row_index": row_index, + "start_sec": np.asarray( + self._read_h5_numeric(entry, "start_sec") if "start_sec" in entry else np.array([], float), + float, + ), + "end_sec": np.asarray( + self._read_h5_numeric(entry, "end_sec") if "end_sec" in entry else np.array([], float), + float, + ), + "duration_sec": np.asarray( + self._read_h5_numeric(entry, "duration_sec") if "duration_sec" in entry else np.array([], float), + float, + ), + } + ) + + return { + "behavior_name": self._h5_text(group.attrs.get("behavior_name", ""), ""), + "per_file_metrics": per_file_metrics, + "per_file_events": per_file_events, + "group_metrics": self._read_h5_json(group, "group_metrics_json"), + "params": self._read_h5_json(group, "params_json"), + } + + def _clear_cached_analysis_outputs(self) -> None: + self.last_signal_events = None + self.last_behavior_analysis = None + self.tbl_signal_metrics.setRowCount(0) + self.tbl_behavior_metrics.setRowCount(0) + self.lbl_behavior_summary.setText("Group metrics: -") + for pw in (self.plot_peak_amp, self.plot_peak_ibi, self.plot_peak_rate): + pw.clear() + for pw in ( + self.plot_behavior_raster, + self.plot_behavior_rate, + self.plot_behavior_duration, + self.plot_behavior_starts, + ): + pw.clear() + self._refresh_signal_overlay() + + def _restore_cached_analysis_outputs(self, payload: Dict[str, object]) -> None: + self._clear_cached_analysis_outputs() + + signal_events = payload.get("signal_events") + if isinstance(signal_events, dict) and signal_events: + self.last_signal_events = signal_events + self._refresh_signal_overlay() + self._render_signal_event_plots() + self._update_signal_metrics_table() + + behavior_analysis = payload.get("behavior_analysis") + if isinstance(behavior_analysis, dict) and behavior_analysis: + self.last_behavior_analysis = behavior_analysis + self._render_behavior_analysis_outputs() + + def _save_project_h5(self, path: str) -> None: + with h5py.File(path, "w") as f: + f.attrs["project_type"] = "pyber_postprocessing_project" + f.attrs["project_version"] = 1 + f.attrs["created_utc"] = datetime.now(timezone.utc).isoformat() + + ui_group = f.create_group("ui") + self._write_h5_json(ui_group, "settings_json", self._collect_settings()) + ui_group.attrs["tab_sources_index"] = int(self.tab_sources.currentIndex()) + + meta_group = f.create_group("meta") + processed_paths = [str(getattr(proc, "path", "") or "").strip() for proc in self._processed] + processed_paths = [p for p in processed_paths if p] + behavior_paths: List[str] = [] + for info in self._behavior_sources.values(): + src = str((info or {}).get("source_path", "") or "").strip() + if src: + behavior_paths.append(src) + self._write_h5_json( + meta_group, + "recent_paths_json", + { + "processed_paths": processed_paths, + "behavior_paths": behavior_paths, + }, + ) + + processed_group = f.create_group("processed") + processed_group.attrs["count"] = int(len(self._processed)) + for idx, proc in enumerate(self._processed): + entry = processed_group.create_group(f"item_{idx:04d}") + entry.attrs["path"] = str(getattr(proc, "path", "") or "") + entry.attrs["channel_id"] = str(getattr(proc, "channel_id", "") or "") + entry.attrs["dio_name"] = str(getattr(proc, "dio_name", "") or "") + entry.attrs["output_label"] = str(getattr(proc, "output_label", "") or "") + entry.attrs["output_context"] = str(getattr(proc, "output_context", "") or "") + entry.attrs["fs_actual"] = float(getattr(proc, "fs_actual", np.nan)) + entry.attrs["fs_target"] = float(getattr(proc, "fs_target", np.nan)) + entry.attrs["fs_used"] = float(getattr(proc, "fs_used", np.nan)) + + self._write_h5_numeric(entry, "time", np.asarray(getattr(proc, "time", np.array([], float)), float)) + self._write_h5_numeric(entry, "raw_signal", np.asarray(getattr(proc, "raw_signal", np.array([], float)), float)) + self._write_h5_numeric(entry, "raw_reference", np.asarray(getattr(proc, "raw_reference", np.array([], float)), float)) + + for field in ( + "raw_thr_hi", + "raw_thr_lo", + "dio", + "sig_f", + "ref_f", + "baseline_sig", + "baseline_ref", + "output", + ): + value = getattr(proc, field, None) + if value is None: + continue + self._write_h5_numeric(entry, field, np.asarray(value, float)) + + artifact_regions = getattr(proc, "artifact_regions_sec", None) + if artifact_regions: + arr = np.asarray(artifact_regions, float).reshape(-1, 2) + self._write_h5_numeric(entry, "artifact_regions_sec", arr) + artifact_regions_auto = getattr(proc, "artifact_regions_auto_sec", None) + if artifact_regions_auto: + arr_auto = np.asarray(artifact_regions_auto, float).reshape(-1, 2) + self._write_h5_numeric(entry, "artifact_regions_auto_sec", arr_auto) + + behavior_group = f.create_group("behavior_sources") + behavior_group.attrs["count"] = int(len(self._behavior_sources)) + for idx, (stem, info) in enumerate(self._behavior_sources.items()): + source = info or {} + entry = behavior_group.create_group(f"item_{idx:04d}") + entry.attrs["stem"] = str(stem) + entry.attrs["kind"] = str(source.get("kind", _BEHAVIOR_PARSE_BINARY)) + entry.attrs["trajectory_time_col"] = str(source.get("trajectory_time_col", "") or "") + if source.get("sheet") is not None: + entry.attrs["sheet"] = str(source.get("sheet")) + if source.get("source_path") is not None: + entry.attrs["source_path"] = str(source.get("source_path")) + + self._write_h5_numeric(entry, "time", np.asarray(source.get("time", np.array([], float)), float)) + self._write_h5_numeric( + entry, + "trajectory_time", + np.asarray(source.get("trajectory_time", np.array([], float)), float), + ) + + behaviors_group = entry.create_group("behaviors") + behaviors = source.get("behaviors") or {} + for b_idx, (name, values) in enumerate(behaviors.items()): + data = np.asarray(values, float) + kwargs: Dict[str, object] = {} + if data.size > 0: + kwargs["compression"] = "gzip" + ds = behaviors_group.create_dataset(f"item_{b_idx:04d}", data=data, **kwargs) + ds.attrs["name"] = str(name) + + trajectory_group = entry.create_group("trajectory") + trajectory = source.get("trajectory") or {} + for t_idx, (name, values) in enumerate(trajectory.items()): + data = np.asarray(values, float) + kwargs: Dict[str, object] = {} + if data.size > 0: + kwargs["compression"] = "gzip" + ds = trajectory_group.create_dataset(f"item_{t_idx:04d}", data=data, **kwargs) + ds.attrs["name"] = str(name) + + analysis_group = f.create_group("analysis") + self._save_signal_events_h5(analysis_group) + self._save_behavior_analysis_h5(analysis_group) + + def _load_project_h5(self, path: str) -> Dict[str, object]: + payload: Dict[str, object] = { + "settings": {}, + "tab_sources_index": 0, + "processed": [], + "behavior_sources": {}, + "recent_paths": {}, + "signal_events": None, + "behavior_analysis": None, + } + with h5py.File(path, "r") as f: + project_type = self._h5_text(f.attrs.get("project_type", ""), "") + if project_type and project_type != "pyber_postprocessing_project": + raise ValueError("This H5 file is not a pyBer postprocessing project.") + if not project_type and "processed" not in f and "ui" not in f: + raise ValueError("This H5 file does not contain a pyBer postprocessing project.") + + ui_group = f.get("ui") + if isinstance(ui_group, h5py.Group): + payload["settings"] = self._read_h5_json(ui_group, "settings_json") + try: + payload["tab_sources_index"] = int(ui_group.attrs.get("tab_sources_index", 0)) + except Exception: + payload["tab_sources_index"] = 0 + + meta_group = f.get("meta") + if isinstance(meta_group, h5py.Group): + payload["recent_paths"] = self._read_h5_json(meta_group, "recent_paths_json") + + loaded_processed: List[ProcessedTrial] = [] + processed_group = f.get("processed") + if isinstance(processed_group, h5py.Group): + for key in sorted(processed_group.keys()): + entry = processed_group.get(key) + if not isinstance(entry, h5py.Group): + continue + time = self._read_h5_numeric(entry, "time") + if time is None or time.size == 0: + continue + t = np.asarray(time, float).reshape(-1) + n = int(t.size) + + def _aligned(values: Optional[np.ndarray], fill_nan: bool = True) -> np.ndarray: + if values is None: + return np.full(n, np.nan, dtype=float) if fill_nan else np.array([], float) + arr = np.asarray(values, float).reshape(-1) + if arr.size == n: + return arr + if arr.size == 0: + return np.full(n, np.nan, dtype=float) if fill_nan else np.array([], float) + out = np.full(n, np.nan, dtype=float) if fill_nan else np.array([], float) + if fill_nan: + m = min(n, arr.size) + out[:m] = arr[:m] + return out + return arr + + raw_signal = _aligned(self._read_h5_numeric(entry, "raw_signal"), fill_nan=True) + raw_reference = _aligned(self._read_h5_numeric(entry, "raw_reference"), fill_nan=True) + output_arr = _aligned(self._read_h5_numeric(entry, "output"), fill_nan=True) + + trial = ProcessedTrial( + path=self._h5_text(entry.attrs.get("path", ""), ""), + channel_id=self._h5_text(entry.attrs.get("channel_id", ""), "import"), + time=t, + raw_signal=raw_signal, + raw_reference=raw_reference, + raw_thr_hi=_aligned(self._read_h5_numeric(entry, "raw_thr_hi"), fill_nan=False) + if "raw_thr_hi" in entry + else None, + raw_thr_lo=_aligned(self._read_h5_numeric(entry, "raw_thr_lo"), fill_nan=False) + if "raw_thr_lo" in entry + else None, + dio=_aligned(self._read_h5_numeric(entry, "dio"), fill_nan=False) if "dio" in entry else None, + dio_name=self._h5_text(entry.attrs.get("dio_name", ""), ""), + sig_f=_aligned(self._read_h5_numeric(entry, "sig_f"), fill_nan=False) if "sig_f" in entry else None, + ref_f=_aligned(self._read_h5_numeric(entry, "ref_f"), fill_nan=False) if "ref_f" in entry else None, + baseline_sig=_aligned(self._read_h5_numeric(entry, "baseline_sig"), fill_nan=False) + if "baseline_sig" in entry + else None, + baseline_ref=_aligned(self._read_h5_numeric(entry, "baseline_ref"), fill_nan=False) + if "baseline_ref" in entry + else None, + output=output_arr, + output_label=self._h5_text(entry.attrs.get("output_label", "output"), "output"), + output_context=self._h5_text(entry.attrs.get("output_context", ""), ""), + artifact_regions_sec=None, + artifact_regions_auto_sec=None, + fs_actual=float(entry.attrs.get("fs_actual", np.nan)), + fs_target=float(entry.attrs.get("fs_target", np.nan)), + fs_used=float(entry.attrs.get("fs_used", np.nan)), + ) + + regions = self._read_h5_numeric(entry, "artifact_regions_sec") + if regions is not None and regions.size: + rr = np.asarray(regions, float).reshape(-1, 2) + trial.artifact_regions_sec = [(float(a), float(b)) for a, b in rr] + regions_auto = self._read_h5_numeric(entry, "artifact_regions_auto_sec") + if regions_auto is not None and regions_auto.size: + ra = np.asarray(regions_auto, float).reshape(-1, 2) + trial.artifact_regions_auto_sec = [(float(a), float(b)) for a, b in ra] + + loaded_processed.append(trial) + + loaded_behavior: Dict[str, Dict[str, Any]] = {} + behavior_group = f.get("behavior_sources") + if isinstance(behavior_group, h5py.Group): + for key in sorted(behavior_group.keys()): + entry = behavior_group.get(key) + if not isinstance(entry, h5py.Group): + continue + stem = self._h5_text(entry.attrs.get("stem", key), key) + info: Dict[str, Any] = { + "kind": self._h5_text(entry.attrs.get("kind", _BEHAVIOR_PARSE_BINARY), _BEHAVIOR_PARSE_BINARY), + "time": np.asarray( + self._read_h5_numeric(entry, "time") if "time" in entry else np.array([], float), + float, + ), + "behaviors": {}, + "trajectory": {}, + "trajectory_time": np.asarray( + self._read_h5_numeric(entry, "trajectory_time") + if "trajectory_time" in entry + else np.array([], float), + float, + ), + "trajectory_time_col": self._h5_text(entry.attrs.get("trajectory_time_col", ""), ""), + } + if "sheet" in entry.attrs: + info["sheet"] = self._h5_text(entry.attrs.get("sheet", ""), "") + if "source_path" in entry.attrs: + info["source_path"] = self._h5_text(entry.attrs.get("source_path", ""), "") + + behaviors_group = entry.get("behaviors") + if isinstance(behaviors_group, h5py.Group): + for b_key in sorted(behaviors_group.keys()): + ds = behaviors_group.get(b_key) + if ds is None: + continue + name = self._h5_text(ds.attrs.get("name", b_key), b_key) + info["behaviors"][name] = np.asarray(ds[()], float) + + trajectory_group = entry.get("trajectory") + if isinstance(trajectory_group, h5py.Group): + for t_key in sorted(trajectory_group.keys()): + ds = trajectory_group.get(t_key) + if ds is None: + continue + name = self._h5_text(ds.attrs.get("name", t_key), t_key) + info["trajectory"][name] = np.asarray(ds[()], float) + + loaded_behavior[stem] = info + + analysis_group = f.get("analysis") + if isinstance(analysis_group, h5py.Group): + payload["signal_events"] = self._load_signal_events_h5(analysis_group) + payload["behavior_analysis"] = self._load_behavior_analysis_h5(analysis_group) + + payload["processed"] = loaded_processed + payload["behavior_sources"] = loaded_behavior + return payload + + def _autosave_project_cache_path(self) -> str: + cache_root = QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.StandardLocation.CacheLocation) + if not cache_root: + cache_root = QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.StandardLocation.AppDataLocation) + if not cache_root: + cache_root = os.path.join(os.getcwd(), "cache") + cache_dir = os.path.join(cache_root, "pyber_postprocessing") + os.makedirs(cache_dir, exist_ok=True) + return os.path.join(cache_dir, "autosave_project.h5") + + def _has_project_state_for_autosave(self) -> bool: + if self._processed or self._behavior_sources: + return True + if isinstance(self.last_signal_events, dict) and bool(self.last_signal_events): + return True + if isinstance(self.last_behavior_analysis, dict) and bool(self.last_behavior_analysis): + return True + return False + + def _clear_project_autosave_cache(self, delete_file: bool = True) -> None: + path = self._settings.value("postprocess_autosave_project_path", "", type=str).strip() + if not path: + path = self._autosave_project_cache_path() + if delete_file and path and os.path.isfile(path): + try: + os.remove(path) + except Exception: + pass + self._settings.setValue("postprocess_autosave_project_available", False) + self._settings.setValue("postprocess_autosave_project_path", "") + self._settings.setValue("postprocess_autosave_project_utc", "") + self._project_recovered_from_autosave = False + + def _autosave_project_to_cache(self) -> None: + if self._autosave_restoring: + return + if not self._has_project_state_for_autosave(): + self._clear_project_autosave_cache(delete_file=True) + return + should_write = bool(self._project_dirty or self._project_recovered_from_autosave) + if not should_write: + self._clear_project_autosave_cache(delete_file=True) + return + path = self._autosave_project_cache_path() + try: + self._save_project_h5(path) + self._settings.setValue("postprocess_autosave_project_available", True) + self._settings.setValue("postprocess_autosave_project_path", path) + self._settings.setValue("postprocess_autosave_project_utc", datetime.now(timezone.utc).isoformat()) + except Exception: + _LOG.exception("Failed to autosave postprocessing project to cache") + + def _restore_project_autosave_if_needed(self) -> None: + available = bool(self._settings.value("postprocess_autosave_project_available", False, type=bool)) + path = self._settings.value("postprocess_autosave_project_path", "", type=str).strip() + if not path: + path = self._autosave_project_cache_path() + if not available: + return + if not path or not os.path.isfile(path): + self._clear_project_autosave_cache(delete_file=False) + return + + try: + self._autosave_restoring = True + ok = self._load_project_from_path(path, from_autosave=True) + finally: + self._autosave_restoring = False + if not ok: + self._clear_project_autosave_cache(delete_file=False) + + def _save_project_file(self) -> None: + start_dir = self._settings.value("postprocess_last_dir", os.getcwd(), type=str) + default_name = f"{self._default_export_prefix()}_project.h5" + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Save postprocessing project", + os.path.join(start_dir, default_name), + "HDF5 project (*.h5)", + ) + if not path: + return + if not path.lower().endswith((".h5", ".hdf5")): + path = f"{path}.h5" + try: + self._save_project_h5(path) + self._push_recent_paths("postprocess_recent_project_paths", [path]) + self._settings.setValue("postprocess_last_dir", os.path.dirname(path)) + self._project_dirty = False + self._project_recovered_from_autosave = False + self._clear_project_autosave_cache(delete_file=True) + self.statusUpdate.emit(f"Project saved: {os.path.basename(path)}", 5000) + except Exception as exc: + QtWidgets.QMessageBox.warning(self, "Save project", f"Could not save project:\n{exc}") + + def _load_project_file(self) -> None: + start_dir = self._settings.value("postprocess_last_dir", os.getcwd(), type=str) + path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Load postprocessing project", + start_dir, + "HDF5 project (*.h5 *.hdf5)", + ) + if not path: + return + self._load_project_from_path(path) + + def _confirm_discard_current_project(self) -> bool: + if not self._project_dirty and not self._has_project_state_for_autosave(): + return True + ask = QtWidgets.QMessageBox.question( + self, + "New project", + "Discard the current postprocessing project and start a new one?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No, + ) + return ask == QtWidgets.QMessageBox.StandardButton.Yes - self.global_bar_amp.setOpts(height=[avg_amp if self.cb_global_amp.isChecked() else 0.0]) - self.global_bar_freq.setOpts(height=[avg_freq if self.cb_global_freq.isChecked() else 0.0]) - ymin = min(0.0, avg_amp if self.cb_global_amp.isChecked() else 0.0, avg_freq if self.cb_global_freq.isChecked() else 0.0) - ymax = max(avg_amp if self.cb_global_amp.isChecked() else 0.0, avg_freq if self.cb_global_freq.isChecked() else 0.0, 0.0) - if ymin == ymax: - ymax = ymin + 1.0 - self.plot_global.setYRange(ymin, ymax, padding=0.2) + def _new_project(self) -> None: + if not self._confirm_discard_current_project(): + return - def _update_metric_regions(self) -> None: - if self._pre_region is not None: - self.plot_avg.removeItem(self._pre_region) - if self._post_region is not None: - self.plot_avg.removeItem(self._post_region) + was_restoring = self._is_restoring_settings + self._is_restoring_settings = True + try: + self._clear_cached_analysis_outputs() + self._processed = [] + self._behavior_sources = {} + self._pending_project_recompute_from_current = False + self.lbl_group.setText("(none)") + self.lbl_beh.setText("(none)") + self.lbl_behavior_msg.setText("") + self.lbl_signal_msg.setText("") + self.lbl_status.setText("") + self.tab_sources.setCurrentIndex(0) + self._update_file_lists() + self._refresh_behavior_list() + self._update_trace_preview() + self._update_behavior_time_panel() + self._update_data_availability() + finally: + self._is_restoring_settings = was_restoring - if not self.cb_metrics.isChecked(): - self._pre_region = None - self._post_region = None - return + self._compute_psth() + self._compute_spatial_heatmap() + self._save_settings() + self._project_dirty = False + self._project_recovered_from_autosave = False + self._clear_project_autosave_cache(delete_file=True) + self._update_status_strip() + self.statusUpdate.emit("Started a new postprocessing project.", 5000) + + def _import_project_source_paths(self, recent_paths: Dict[str, object]) -> bool: + proc_raw = recent_paths.get("processed_paths", []) if isinstance(recent_paths, dict) else [] + beh_raw = recent_paths.get("behavior_paths", []) if isinstance(recent_paths, dict) else [] + proc_paths = [str(p).strip() for p in (proc_raw if isinstance(proc_raw, list) else []) if str(p).strip()] + beh_paths = [str(p).strip() for p in (beh_raw if isinstance(beh_raw, list) else []) if str(p).strip()] + proc_existing = [p for p in proc_paths if os.path.isfile(p)] + beh_existing = [p for p in beh_paths if os.path.isfile(p)] + if not proc_existing and not beh_existing: + return False + + if proc_existing: + self._load_processed_paths(proc_existing, replace=True) + if beh_existing: + self._load_behavior_paths(beh_existing, replace=True) + self._refresh_behavior_list() + return bool(proc_existing or beh_existing) + + def _load_project_from_path(self, path: str, from_autosave: bool = False) -> bool: + try: + payload = self._load_project_h5(path) + except Exception as exc: + QtWidgets.QMessageBox.warning(self, "Load project", f"Could not load project:\n{exc}") + return False + + settings_data = payload.get("settings", {}) + processed = payload.get("processed", []) + behavior_sources = payload.get("behavior_sources", {}) + tab_idx = payload.get("tab_sources_index", 0) + recent_paths = payload.get("recent_paths", {}) if isinstance(payload.get("recent_paths", {}), dict) else {} + + was_restoring = self._is_restoring_settings + self._is_restoring_settings = True + try: + self._clear_cached_analysis_outputs() + self._processed = list(processed) if isinstance(processed, list) else [] + self._behavior_sources = dict(behavior_sources) if isinstance(behavior_sources, dict) else {} + + self.lbl_group.setText(f"{len(self._processed)} file(s) loaded") + kinds = { + str((info or {}).get("kind", "")).strip() + for info in self._behavior_sources.values() + if isinstance(info, dict) + } + if len(kinds) == 1 and _BEHAVIOR_PARSE_TIMESTAMPS in kinds: + mode_label = "timestamps" + elif len(kinds) == 1 and _BEHAVIOR_PARSE_BINARY in kinds: + mode_label = "binary" + elif len(kinds) > 1: + mode_label = "mixed" + else: + mode_label = "timestamps" if self._current_behavior_parse_mode() == _BEHAVIOR_PARSE_TIMESTAMPS else "binary" + self.lbl_beh.setText(f"{len(self._behavior_sources)} file(s) loaded [{mode_label}]") - pre0 = float(self.spin_metric_pre0.value()) - pre1 = float(self.spin_metric_pre1.value()) - post0 = float(self.spin_metric_post0.value()) - post1 = float(self.spin_metric_post1.value()) + self._update_file_lists() + self._refresh_behavior_list() + self._set_resample_from_processed() + if isinstance(settings_data, dict) and settings_data: + self._apply_settings(settings_data) + if isinstance(tab_idx, int) and 0 <= tab_idx < self.tab_sources.count(): + self.tab_sources.setCurrentIndex(tab_idx) + self._update_trace_preview() + self._update_data_availability() + finally: + self._is_restoring_settings = was_restoring + + proc_paths = recent_paths.get("processed_paths", []) if isinstance(recent_paths, dict) else [] + beh_paths = recent_paths.get("behavior_paths", []) if isinstance(recent_paths, dict) else [] + if isinstance(proc_paths, list) and proc_paths: + self._push_recent_paths("postprocess_recent_processed_paths", [str(p) for p in proc_paths if str(p).strip()]) + if isinstance(beh_paths, list) and beh_paths: + self._push_recent_paths("postprocess_recent_behavior_paths", [str(p) for p in beh_paths if str(p).strip()]) + + if not from_autosave: + self._push_recent_paths("postprocess_recent_project_paths", [path]) + try: + self._settings.setValue("postprocess_last_dir", os.path.dirname(path)) + except Exception: + pass - self._pre_region = pg.LinearRegionItem(values=(pre0, pre1), brush=(90, 143, 214, 60), movable=False) - self._post_region = pg.LinearRegionItem(values=(post0, post1), brush=(214, 122, 90, 60), movable=False) - self.plot_avg.addItem(self._pre_region) - self.plot_avg.addItem(self._post_region) + proc_raw = recent_paths.get("processed_paths", []) if isinstance(recent_paths, dict) else [] + beh_raw = recent_paths.get("behavior_paths", []) if isinstance(recent_paths, dict) else [] + proc_existing = [str(p).strip() for p in (proc_raw if isinstance(proc_raw, list) else []) if str(p).strip() and os.path.isfile(str(p).strip())] + beh_existing = [str(p).strip() for p in (beh_raw if isinstance(beh_raw, list) else []) if str(p).strip() and os.path.isfile(str(p).strip())] + has_referenced_sources = bool(proc_existing or beh_existing) + + imported_sources = False + if has_referenced_sources and not from_autosave: + ask_sources = QtWidgets.QMessageBox.question( + self, + "Load project", + "Import linked source files from this project (last opened data)?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No, + ) + if ask_sources == QtWidgets.QMessageBox.StandardButton.Yes: + imported_sources = self._import_project_source_paths(recent_paths) - def _open_style_dialog(self) -> None: - dlg = StyleDialog(self._style, self) - if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: - return - self._style = dlg.get_style() - self.curve_trace.setPen(pg.mkPen(self._style["trace"], width=1.1)) - self.curve_behavior.setPen(pg.mkPen(self._style["behavior"], width=1.0)) - self.curve_avg.setPen(pg.mkPen(self._style["avg"], width=1.3)) - self._render_heatmap(self._last_mat if self._last_mat is not None else np.zeros((1, 1)), self._last_tvec if self._last_tvec is not None else np.array([0.0, 1.0])) + if self._processed: + self._compute_psth() + self._compute_spatial_heatmap() + elif not imported_sources and not from_autosave: + ask = QtWidgets.QMessageBox.question( + self, + "Load project", + "Project loaded without processed traces. Import current preprocessing selection now?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.Yes, + ) + if ask == QtWidgets.QMessageBox.StandardButton.Yes: + self._pending_project_recompute_from_current = True + self.requestCurrentProcessed.emit() + + self._restore_cached_analysis_outputs(payload) self._save_settings() + self._update_status_strip() + self._project_dirty = False + self._project_recovered_from_autosave = bool(from_autosave) + if from_autosave: + self.statusUpdate.emit("Recovered autosaved postprocessing project.", 5000) + else: + self.statusUpdate.emit(f"Project loaded: {os.path.basename(path)}", 5000) + return True def _save_config_file(self) -> None: start_dir = self._settings.value("postprocess_last_dir", os.getcwd(), type=str) @@ -2025,6 +7032,7 @@ def _load_config_file(self) -> None: data = json.load(f) self._apply_settings(data) self._compute_psth() + self._compute_spatial_heatmap() self._settings.setValue("postprocess_last_dir", os.path.dirname(path)) except Exception: pass @@ -2035,6 +7043,8 @@ def _collect_settings(self) -> Dict[str, object]: "dio_channel": self.combo_dio.currentText(), "dio_polarity": self.combo_dio_polarity.currentText(), "dio_align": self.combo_dio_align.currentText(), + "behavior_file_type": self.combo_behavior_file_type.currentText(), + "behavior_time_fps": float(self.spin_behavior_fps.value()), "behavior": self.combo_behavior_name.currentText(), "behavior_align": self.combo_behavior_align.currentText(), "behavior_from": self.combo_behavior_from.currentText(), @@ -2049,6 +7059,7 @@ def _collect_settings(self) -> Dict[str, object]: "filter_enabled": self.cb_filter_events.isChecked(), "event_start": int(self.spin_event_start.value()), "event_end": int(self.spin_event_end.value()), + "group_window_s": float(self.spin_group_window.value()), "dur_min": float(self.spin_dur_min.value()), "dur_max": float(self.spin_dur_max.value()), "metrics_enabled": self.cb_metrics.isChecked(), @@ -2062,6 +7073,39 @@ def _collect_settings(self) -> Dict[str, object]: "global_end": float(self.spin_global_end.value()), "global_amp": self.cb_global_amp.isChecked(), "global_freq": self.cb_global_freq.isChecked(), + "view_layout": self.combo_view_layout.currentText(), + "signal_source": self.combo_signal_source.currentText(), + "signal_scope": self.combo_signal_scope.currentText(), + "signal_file": self.combo_signal_file.currentText(), + "signal_method": self.combo_signal_method.currentText(), + "signal_prominence": float(self.spin_peak_prominence.value()), + "signal_height": float(self.spin_peak_height.value()), + "signal_distance": float(self.spin_peak_distance.value()), + "signal_smooth": float(self.spin_peak_smooth.value()), + "signal_baseline": self.combo_peak_baseline.currentText(), + "signal_baseline_window": float(self.spin_peak_baseline_window.value()), + "signal_rate_bin": float(self.spin_peak_rate_bin.value()), + "signal_auc_window": float(self.spin_peak_auc_window.value()), + "signal_overlay": self.cb_peak_overlay.isChecked(), + "behavior_analysis_name": self.combo_behavior_analysis.currentText(), + "behavior_analysis_bin": float(self.spin_behavior_bin.value()), + "behavior_analysis_aligned": self.cb_behavior_aligned.isChecked(), + "spatial_x": self.combo_spatial_x.currentText(), + "spatial_y": self.combo_spatial_y.currentText(), + "spatial_bins_x": int(self.spin_spatial_bins_x.value()), + "spatial_bins_y": int(self.spin_spatial_bins_y.value()), + "spatial_weight": self.combo_spatial_weight.currentText(), + "spatial_clip": self.cb_spatial_clip.isChecked(), + "spatial_clip_low": float(self.spin_spatial_clip_low.value()), + "spatial_clip_high": float(self.spin_spatial_clip_high.value()), + "spatial_time_filter": self.cb_spatial_time_filter.isChecked(), + "spatial_time_min": float(self.spin_spatial_time_min.value()), + "spatial_time_max": float(self.spin_spatial_time_max.value()), + "spatial_smooth": float(self.spin_spatial_smooth.value()), + "spatial_activity_mode": self.combo_spatial_activity_mode.currentText(), + "spatial_activity_norm": self.combo_spatial_activity_mode.currentText().strip().lower().startswith("mean"), + "spatial_log": self.cb_spatial_log.isChecked(), + "spatial_invert_y": self.cb_spatial_invert_y.isChecked(), "style": dict(self._style), } @@ -2069,6 +7113,8 @@ def _apply_settings(self, data: Dict[str, object]) -> None: def _set_combo(combo: QtWidgets.QComboBox, val: object) -> None: if val is None: return + if combo is self.combo_align and str(val) == "DIO (from Doric)": + val = "Analog/Digital channel (from Doric)" idx = combo.findText(str(val)) if idx >= 0: combo.setCurrentIndex(idx) @@ -2077,6 +7123,9 @@ def _set_combo(combo: QtWidgets.QComboBox, val: object) -> None: _set_combo(self.combo_dio, data.get("dio_channel")) _set_combo(self.combo_dio_polarity, data.get("dio_polarity")) _set_combo(self.combo_dio_align, data.get("dio_align")) + _set_combo(self.combo_behavior_file_type, data.get("behavior_file_type")) + if "behavior_time_fps" in data: + self.spin_behavior_fps.setValue(float(data["behavior_time_fps"])) _set_combo(self.combo_behavior_name, data.get("behavior")) _set_combo(self.combo_behavior_align, data.get("behavior_align")) _set_combo(self.combo_behavior_from, data.get("behavior_from")) @@ -2100,6 +7149,8 @@ def _set_combo(combo: QtWidgets.QComboBox, val: object) -> None: self.spin_event_start.setValue(int(data["event_start"])) if "event_end" in data: self.spin_event_end.setValue(int(data["event_end"])) + if "group_window_s" in data: + self.spin_group_window.setValue(float(data["group_window_s"])) if "dur_min" in data: self.spin_dur_min.setValue(float(data["dur_min"])) if "dur_max" in data: @@ -2123,16 +7174,82 @@ def _set_combo(combo: QtWidgets.QComboBox, val: object) -> None: self.cb_global_amp.setChecked(bool(data["global_amp"])) if "global_freq" in data: self.cb_global_freq.setChecked(bool(data["global_freq"])) + _set_combo(self.combo_view_layout, data.get("view_layout")) + _set_combo(self.combo_signal_source, data.get("signal_source")) + _set_combo(self.combo_signal_scope, data.get("signal_scope")) + self._refresh_signal_file_combo() + _set_combo(self.combo_signal_file, data.get("signal_file")) + _set_combo(self.combo_signal_method, data.get("signal_method")) + if "signal_prominence" in data: + self.spin_peak_prominence.setValue(float(data["signal_prominence"])) + if "signal_height" in data: + self.spin_peak_height.setValue(float(data["signal_height"])) + if "signal_distance" in data: + self.spin_peak_distance.setValue(float(data["signal_distance"])) + if "signal_smooth" in data: + self.spin_peak_smooth.setValue(float(data["signal_smooth"])) + _set_combo(self.combo_peak_baseline, data.get("signal_baseline")) + if "signal_baseline_window" in data: + self.spin_peak_baseline_window.setValue(float(data["signal_baseline_window"])) + if "signal_rate_bin" in data: + self.spin_peak_rate_bin.setValue(float(data["signal_rate_bin"])) + if "signal_auc_window" in data: + self.spin_peak_auc_window.setValue(float(data["signal_auc_window"])) + if "signal_overlay" in data: + self.cb_peak_overlay.setChecked(bool(data["signal_overlay"])) + _set_combo(self.combo_behavior_analysis, data.get("behavior_analysis_name")) + if "behavior_analysis_bin" in data: + self.spin_behavior_bin.setValue(float(data["behavior_analysis_bin"])) + if "behavior_analysis_aligned" in data: + self.cb_behavior_aligned.setChecked(bool(data["behavior_analysis_aligned"])) + _set_combo(self.combo_spatial_x, data.get("spatial_x")) + _set_combo(self.combo_spatial_y, data.get("spatial_y")) + if "spatial_bins_x" in data: + self.spin_spatial_bins_x.setValue(int(data["spatial_bins_x"])) + if "spatial_bins_y" in data: + self.spin_spatial_bins_y.setValue(int(data["spatial_bins_y"])) + _set_combo(self.combo_spatial_weight, data.get("spatial_weight")) + if "spatial_clip" in data: + self.cb_spatial_clip.setChecked(bool(data["spatial_clip"])) + if "spatial_clip_low" in data: + self.spin_spatial_clip_low.setValue(float(data["spatial_clip_low"])) + if "spatial_clip_high" in data: + self.spin_spatial_clip_high.setValue(float(data["spatial_clip_high"])) + if "spatial_time_filter" in data: + self.cb_spatial_time_filter.setChecked(bool(data["spatial_time_filter"])) + if "spatial_time_min" in data: + self.spin_spatial_time_min.setValue(float(data["spatial_time_min"])) + if "spatial_time_max" in data: + self.spin_spatial_time_max.setValue(float(data["spatial_time_max"])) + if "spatial_smooth" in data: + self.spin_spatial_smooth.setValue(float(data["spatial_smooth"])) + if "spatial_activity_mode" in data: + _set_combo(self.combo_spatial_activity_mode, data.get("spatial_activity_mode")) + elif "spatial_activity_norm" in data: + if bool(data["spatial_activity_norm"]): + _set_combo(self.combo_spatial_activity_mode, "Mean z-score/bin (occupancy normalized)") + else: + _set_combo(self.combo_spatial_activity_mode, "Sum z-score/bin") + if "spatial_log" in data: + self.cb_spatial_log.setChecked(bool(data["spatial_log"])) + if "spatial_invert_y" in data: + self.cb_spatial_invert_y.setChecked(bool(data["spatial_invert_y"])) style = data.get("style") if isinstance(style, dict): self._style.update(style) - self.curve_trace.setPen(pg.mkPen(self._style["trace"], width=1.1)) - self.curve_behavior.setPen(pg.mkPen(self._style["behavior"], width=1.0)) - self.curve_avg.setPen(pg.mkPen(self._style["avg"], width=1.3)) + self._apply_plot_style() + self._apply_behavior_time_settings() self._update_event_filter_enabled() self._update_metrics_enabled() self._update_global_metrics_enabled() + self._update_spatial_clip_enabled() + self._update_spatial_time_filter_enabled() self._update_metric_regions() + self._apply_view_layout() + self._refresh_signal_file_combo() + self._compute_spatial_heatmap() + self._update_data_availability() + self._update_status_strip() def _save_settings(self) -> None: try: @@ -2140,8 +7257,15 @@ def _save_settings(self) -> None: self._settings.setValue("postprocess_json", json.dumps(data)) except Exception: pass + self._save_panel_layout_state() + try: + self._settings.sync() + except Exception: + pass def _restore_settings(self) -> None: + was_restoring = self._is_restoring_settings + self._is_restoring_settings = True try: raw = self._settings.value("postprocess_json", "", type=str) if raw: @@ -2149,6 +7273,8 @@ def _restore_settings(self) -> None: self._apply_settings(data) except Exception: pass + finally: + self._is_restoring_settings = was_restoring def _export_origin_dir(self) -> str: if self._processed: @@ -2186,6 +7312,97 @@ def _remember_export_dir(self, out_dir: str) -> None: except Exception: pass + def _default_export_prefix(self) -> str: + prefix = "postprocess" + if self._processed: + prefix = os.path.splitext(os.path.basename(self._processed[0].path))[0] + beh_suffix = self._behavior_suffix() + if beh_suffix: + prefix = f"{prefix}_{beh_suffix}" + return prefix + + def _render_widget_image( + self, + widget: QtWidgets.QWidget, + transparent: bool = True, + ) -> Optional[QtGui.QImage]: + if widget is None: + return None + size = widget.size() + if not size.isValid() or size.width() <= 0 or size.height() <= 0: + size = widget.sizeHint() + if not size.isValid() or size.width() <= 0 or size.height() <= 0: + size = QtCore.QSize(1280, 720) + dpr = 1.0 + try: + dpr = max(1.0, float(widget.devicePixelRatioF())) + except Exception: + dpr = 1.0 + image = QtGui.QImage( + max(1, int(round(size.width() * dpr))), + max(1, int(round(size.height() * dpr))), + QtGui.QImage.Format.Format_ARGB32, + ) + image.setDevicePixelRatio(dpr) + if transparent: + image.fill(QtCore.Qt.GlobalColor.transparent) + else: + image.fill(QtGui.QColor(255, 255, 255)) + painter = QtGui.QPainter(image) + try: + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True) + # Keep alpha background while rendering children. + widget.render( + painter, + QtCore.QPoint(), + QtGui.QRegion(), + QtWidgets.QWidget.RenderFlag.DrawChildren, + ) + finally: + painter.end() + return image + + def _write_widget_pdf(self, widget: QtWidgets.QWidget, path: str) -> bool: + if widget is None: + return False + size = widget.size() + if not size.isValid() or size.width() <= 0 or size.height() <= 0: + size = widget.sizeHint() + if not size.isValid() or size.width() <= 0 or size.height() <= 0: + size = QtCore.QSize(1280, 720) + writer = QtGui.QPdfWriter(path) + writer.setResolution(300) + width_px = float(size.width()) + height_px = float(size.height()) + width_pt = max(1.0, width_px * 72.0 / 96.0) + height_pt = max(1.0, height_px * 72.0 / 96.0) + writer.setPageSize(QtGui.QPageSize(QtCore.QSizeF(width_pt, height_pt), QtGui.QPageSize.Unit.Point)) + writer.setPageMargins(QtCore.QMarginsF(0.0, 0.0, 0.0, 0.0)) + painter = QtGui.QPainter(writer) + try: + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True) + widget.render( + painter, + QtCore.QPoint(), + QtGui.QRegion(), + QtWidgets.QWidget.RenderFlag.DrawChildren, + ) + finally: + painter.end() + return True + + def _export_widget_png_pdf(self, widget: QtWidgets.QWidget, base_path: str, transparent: bool = True) -> Tuple[bool, Optional[str], Optional[str]]: + image = self._render_widget_image(widget, transparent=transparent) + if image is None or image.isNull(): + return False, None, None + png_path = f"{base_path}.png" + pdf_path = f"{base_path}.pdf" + ok_png = bool(image.save(png_path, "PNG")) + ok_pdf = self._write_widget_pdf(widget, pdf_path) + return (ok_png and ok_pdf), (png_path if ok_png else None), (pdf_path if ok_pdf else None) + def _export_results(self) -> None: if self._last_mat is None or self._last_tvec is None: return @@ -2198,23 +7415,47 @@ def _export_results(self) -> None: if not out_dir: return self._remember_export_dir(out_dir) - prefix = "postprocess" - if self._processed: - prefix = os.path.splitext(os.path.basename(self._processed[0].path))[0] - beh_suffix = self._behavior_suffix() - if beh_suffix: - prefix = f"{prefix}_{beh_suffix}" + prefix = self._default_export_prefix() if choices.get("heatmap"): - np.savetxt(os.path.join(out_dir, f"{prefix}_heatmap.csv"), self._last_mat, delimiter=",") - np.savetxt(os.path.join(out_dir, f"{prefix}_heatmap_tvec.csv"), self._last_tvec, delimiter=",") + mat = np.asarray(self._last_mat, float) + if mat.ndim == 1: + mat = mat[np.newaxis, :] + time = np.asarray(self._last_tvec, float) + n_time = min(time.size, mat.shape[1]) + time = time[:n_time] + mat = mat[:, :n_time] + arr = np.column_stack([time, mat.T]) + header_cols = ["time"] + [f"trial_{i + 1}" for i in range(mat.shape[0])] + np.savetxt( + os.path.join(out_dir, f"{prefix}_heatmap.csv"), + arr, + delimiter=",", + header=",".join(header_cols), + comments="", + ) if choices.get("avg"): avg = np.nanmean(self._last_mat, axis=0) sem = np.nanstd(self._last_mat, axis=0) / np.sqrt(max(1, np.sum(np.any(np.isfinite(self._last_mat), axis=1)))) arr = np.vstack([self._last_tvec, avg, sem]).T - np.savetxt(os.path.join(out_dir, f"{prefix}_avg_psth.csv"), arr, delimiter=",", header="time,avg,sem", comments="") - if choices.get("events") and self._last_events is not None: - np.savetxt(os.path.join(out_dir, f"{prefix}_events.csv"), self._last_events, delimiter=",") + np.savetxt( + os.path.join(out_dir, f"{prefix}_avg_psth.csv"), + arr, + delimiter=",", + header="time,average_psth,sem", + comments="", + ) + if choices.get("events"): + event_path = os.path.join(out_dir, f"{prefix}_events.csv") + if self._last_event_rows: + import csv + with open(event_path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["file_id", "event_time_sec", "duration_sec"]) + for row in self._last_event_rows: + w.writerow([row.get("file_id", ""), row.get("event_time_sec", np.nan), row.get("duration_sec", np.nan)]) + elif self._last_events is not None: + np.savetxt(event_path, self._last_events, delimiter=",") if choices.get("durations") and self._last_durations is not None: np.savetxt(os.path.join(out_dir, f"{prefix}_durations.csv"), self._last_durations, delimiter=",") if choices.get("metrics") and (self._last_metrics or self._last_global_metrics): @@ -2255,29 +7496,657 @@ def _behavior_suffix(self) -> str: return cleaned def _export_images(self) -> None: + if not hasattr(self, "_right_panel"): + return + dlg = ExportImageDialog(self) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + choices = dlg.choices() + + export_targets: List[Tuple[str, QtWidgets.QWidget]] = [] + if choices.get("all", False): + export_targets.append(("psth_figure", self._right_panel)) + else: + if choices.get("trace"): + export_targets.append(("trace", self.plot_trace)) + if choices.get("heat"): + export_targets.append(("heatmap", self.row_heat)) + if choices.get("avg"): + export_targets.append(("avg_metrics", self.row_avg)) + if choices.get("signal"): + export_targets.append(("signal", self.row_signal)) + if choices.get("behavior"): + export_targets.append(("behavior", self.row_behavior)) + if choices.get("spatial"): + target_spatial = getattr(self, "spatial_plot_content", None) or getattr(self, "spatial_plot_dialog", None) + if target_spatial is not None: + export_targets.append(("spatial", target_spatial)) + + if not export_targets: + QtWidgets.QMessageBox.information(self, "Export images", "Select at least one panel to export.") + return + start_dir = self._export_start_dir() + prefix = self._default_export_prefix() + if len(export_targets) == 1: + suffix, widget = export_targets[0] + default_path = os.path.join(start_dir, f"{prefix}_{suffix}.png") + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export image (PNG + PDF)", + default_path, + "PNG image (*.png);;All files (*.*)", + ) + if not path: + return + if not os.path.splitext(path)[1]: + path = f"{path}.png" + base_path, _ = os.path.splitext(path) + out_dir = os.path.dirname(base_path) + if out_dir: + self._remember_export_dir(out_dir) + ok, png_path, pdf_path = self._export_widget_png_pdf(widget, base_path, transparent=True) + if ok: + self.statusUpdate.emit( + f"Exported image: {os.path.basename(png_path or '')}, {os.path.basename(pdf_path or '')}", + 5000, + ) + else: + QtWidgets.QMessageBox.warning(self, "Export failed", "Could not export selected panel as PNG/PDF.") + return + out_dir = QtWidgets.QFileDialog.getExistingDirectory(self, "Select export folder", start_dir) if not out_dir: return self._remember_export_dir(out_dir) - prefix = "postprocess" - if self._processed: - prefix = os.path.splitext(os.path.basename(self._processed[0].path))[0] + ok_count = 0 + failed: List[str] = [] + for suffix, widget in export_targets: + base_path = os.path.join(out_dir, f"{prefix}_{suffix}") + ok, _png_path, _pdf_path = self._export_widget_png_pdf(widget, base_path, transparent=True) + if ok: + ok_count += 1 + else: + failed.append(suffix) + if failed: + QtWidgets.QMessageBox.warning( + self, + "Export images", + f"Exported {ok_count}/{len(export_targets)} panel(s).\nFailed: {', '.join(failed)}", + ) + else: + self.statusUpdate.emit(f"Exported {ok_count} panel image set(s) (PNG + PDF).", 5000) - targets = { - "overview": self._right_panel, - "trace": self.plot_trace, - "heatmap": self.plot_heat, - "duration": self.plot_dur, - "avg": self.plot_avg, - "metrics": self.plot_metrics, - } - for name, widget in targets.items(): + def _export_spatial_figure(self) -> None: + if self._last_spatial_occupancy_map is None or self._last_spatial_extent is None: + QtWidgets.QMessageBox.information(self, "Spatial heatmap", "Compute spatial heatmap first.") + return + target = getattr(self, "spatial_plot_content", None) or getattr(self, "spatial_plot_dialog", None) + if target is None: + return + start_dir = self._export_start_dir() + prefix = self._default_export_prefix() + default_path = os.path.join(start_dir, f"{prefix}_spatial_figure.png") + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export spatial figure (PNG + PDF)", + default_path, + "PNG image (*.png);;All files (*.*)", + ) + if not path: + return + if not os.path.splitext(path)[1]: + path = f"{path}.png" + base_path, _ = os.path.splitext(path) + out_dir = os.path.dirname(base_path) + if out_dir: + self._remember_export_dir(out_dir) + ok, png_path, pdf_path = self._export_widget_png_pdf(target, base_path, transparent=True) + if ok: + self.statusUpdate.emit(f"Exported spatial figure: {os.path.basename(png_path or '')}, {os.path.basename(pdf_path or '')}", 5000) + else: + QtWidgets.QMessageBox.warning(self, "Export failed", "Could not export spatial figure as PNG/PDF.") + + def hideEvent(self, event: QtGui.QHideEvent) -> None: + super().hideEvent(event) + if self._app_closing: + return + try: + if hasattr(self, "spatial_plot_dialog"): + self.spatial_plot_dialog.hide() + except Exception: + pass + self.hide_section_popups_for_tab_switch() + + def hide_section_popups_for_tab_switch(self) -> None: + """Hide and detach post-processing docks when tab is inactive.""" + if self._use_pg_dockarea_layout: + # DockArea lives inside the post tab widget; no main-window dock mutation is needed. + return + if not self._section_popups: + return + if self._applying_fixed_default_layout: + return + if self._force_fixed_default_layout: + self._suspend_panel_layout_persistence = True + try: + for key, dock in self._section_popups.items(): + dock.hide() + self._set_section_button_checked(key, False) + finally: + self._suspend_panel_layout_persistence = False + # Fixed mode does not use cached hidden-state restore. + self._post_docks_hidden_for_tab_switch = False + self._post_section_visibility_before_hide.clear() + self._post_section_state_before_hide.clear() + return + if self._post_docks_hidden_for_tab_switch: + return + host = self._dock_host or self._dock_main_window() + self._post_section_visibility_before_hide = {} + self._post_section_state_before_hide = {} + for key, dock in self._section_popups.items(): + visible = bool(dock.isVisible()) + self._post_section_visibility_before_hide[key] = visible + area = ( + _dock_area_to_int(host.dockWidgetArea(dock), _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2)) + if host is not None + else _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + ) + self._post_section_state_before_hide[key] = { + "visible": visible, + "floating": bool(dock.isFloating()), + "area": area, + "geometry": dock.saveGeometry(), + } + try: + if host is not None: + state = None + if hasattr(host, "captureDockSnapshotForTab"): + state = host.captureDockSnapshotForTab("post") + else: + state = host.saveState(_DOCK_STATE_VERSION) + if state is not None and not state.isEmpty(): + self._settings.setValue(_POST_DOCK_STATE_KEY, state) + self._settings.sync() + except Exception: + pass + # Mark tab-switch hide before any dock visibility changes so delayed signals + # do not persist temporary hidden/default states. + self._post_docks_hidden_for_tab_switch = True + self._persist_hidden_layout_state_from_cache() + self._suspend_panel_layout_persistence = True + try: + for key, dock in self._section_popups.items(): + dock.hide() + # Detach post docks while tab is inactive so preprocessing and postprocessing + # layouts cannot mutate each other in the shared main-window dock host. + if host is not None: + try: + host.removeDockWidget(dock) + except Exception: + pass + self._set_section_button_checked(key, False) + finally: + self._suspend_panel_layout_persistence = False + + def _ensure_plot_rows_visible(self) -> None: + """Guarantee the plot area remains visible after tab switches and dock operations.""" + if hasattr(self, "_right_panel"): + self._right_panel.setVisible(True) + if not ( + self.plot_trace.isVisible() + or self.row_heat.isVisible() + or self.row_avg.isVisible() + ): + self.combo_view_layout.blockSignals(True) + self.combo_view_layout.setCurrentText("Standard") + self.combo_view_layout.blockSignals(False) + self._apply_view_layout() + + def showEvent(self, event: QtGui.QShowEvent) -> None: + super().showEvent(event) + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + if self._force_fixed_default_layout: + if not self._dock_layout_restored: + self._apply_fixed_dockarea_layout() + else: + self._sync_section_button_states_from_docks() + elif not self._dock_layout_restored: + self._restore_dockarea_layout_state() + self._dock_layout_restored = True + self._ensure_plot_rows_visible() + return + self._setup_section_popups() + if not self._section_popups: + # Defer until the widget is fully attached to a main-window host. + QtCore.QTimer.singleShot(0, self._setup_section_popups) + if self._force_fixed_default_layout and self._section_popups: + self.apply_fixed_default_layout() + self._dock_layout_restored = True + elif not self._dock_layout_restored and self._section_popups: + self._restore_panel_layout_state() + self._dock_layout_restored = True + + if not self._force_fixed_default_layout: + self._apply_post_main_dock_snapshot_if_needed() + self._enforce_only_post_docks_visible() + self._ensure_plot_rows_visible() + if self._force_fixed_default_layout: + # Fixed mode ignores cached tab-switch floating/visibility state. + self._post_docks_hidden_for_tab_switch = False + self._post_section_visibility_before_hide.clear() + self._post_section_state_before_hide.clear() + return + if not self._section_popups: + return + if not self._post_docks_hidden_for_tab_switch: + return + host = self._dock_host or self._dock_main_window() + self._suspend_panel_layout_persistence = True + try: + for key, dock in self._section_popups.items(): + state = self._post_section_state_before_hide.get(key, {}) + visible = bool(state.get("visible", self._post_section_visibility_before_hide.get(key, False))) + floating = bool(state.get("floating", dock.isFloating())) + area = self._dock_area_from_settings( + state.get("area", _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2)), + QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + ) + geom = state.get("geometry") + dock.blockSignals(True) + try: + if floating: + dock.setFloating(True) + if isinstance(geom, QtCore.QByteArray) and not geom.isEmpty(): + dock.restoreGeometry(geom) + self._section_popup_initialized.add(key) + if key not in self._section_popup_initialized or not self._is_popup_on_screen(dock): + self._position_section_popup(dock, key) + self._section_popup_initialized.add(key) + else: + if host is not None: + host.addDockWidget(area, dock) + dock.setFloating(False) + if isinstance(geom, QtCore.QByteArray) and not geom.isEmpty(): + dock.restoreGeometry(geom) + finally: + dock.blockSignals(False) + if visible: + dock.show() + self._set_section_button_checked(key, True) + self._last_opened_section = key + else: + dock.hide() + self._set_section_button_checked(key, False) + finally: + self._suspend_panel_layout_persistence = False + self._post_docks_hidden_for_tab_switch = False + self._post_section_visibility_before_hide.clear() + self._post_section_state_before_hide.clear() + self._enforce_only_post_docks_visible() + self._ensure_plot_rows_visible() + self._save_panel_layout_state() + + def _apply_post_main_dock_snapshot_if_needed(self) -> None: + if self._use_pg_dockarea_layout: + return + if self._post_snapshot_applied: + return + host = self._dock_host or self._dock_main_window() + if host is None: + return + try: + raw = self._settings.value(_POST_DOCK_STATE_KEY, None) + state = self._to_qbytearray(raw) + if state is not None and not state.isEmpty(): + if hasattr(host, "restoreDockSnapshotForTab"): + ok = bool(host.restoreDockSnapshotForTab("post", state)) + else: + ok = bool(host.restoreState(state, _DOCK_STATE_VERSION)) + if ok: + self._post_snapshot_applied = True + _LOG.info("Post dock snapshot applied successfully") + else: + _LOG.warning("Post dock snapshot restore failed") + self._sync_section_button_states_from_docks() + self._enforce_only_post_docks_visible() + except Exception: + _LOG.exception("Post dock snapshot restore crashed") + + def _on_about_to_quit(self) -> None: + self._app_closing = True + try: + if hasattr(self, "spatial_plot_dialog"): + self.spatial_plot_dialog.hide() + except Exception: + pass + self._autosave_project_to_cache() + self.persist_layout_state_snapshot() + self._save_settings() + + def _enforce_only_post_docks_visible(self) -> None: + """ + Ensure preprocessing docks cannot stay visible while postprocessing tab is active. + """ + if self._use_pg_dockarea_layout: + return + if not self.isVisible(): + return + host = self._dock_host or self._dock_main_window() + if host is None: + return + tabs = getattr(host, "tabs", None) + if isinstance(tabs, QtWidgets.QTabWidget) and tabs.currentWidget() is not self: + return + for dock in host.findChildren(QtWidgets.QDockWidget): + name = str(dock.objectName() or "") + if name.startswith(_PRE_DOCK_PREFIX): + dock.hide() + + def mark_app_closing(self) -> None: + self._app_closing = True + + def set_force_fixed_default_layout(self, enabled: bool) -> None: + self._force_fixed_default_layout = bool(enabled) + self._apply_fixed_dock_features() + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + if self._force_fixed_default_layout: + self._apply_fixed_dockarea_layout() + else: + self._restore_dockarea_layout_state() + return + + def _schedule_fixed_layout_retry(self) -> None: + if self._pending_fixed_layout_retry: + return + self._pending_fixed_layout_retry = True + QtCore.QTimer.singleShot(0, self._retry_apply_fixed_default_layout) + + def _retry_apply_fixed_default_layout(self) -> None: + self._pending_fixed_layout_retry = False + try: + self.apply_fixed_default_layout() + except Exception: + _LOG.exception("Deferred fixed post layout apply failed") + + def _ensure_fixed_right_tab_widget(self, host: QtWidgets.QMainWindow) -> Optional[QtWidgets.QTabWidget]: + setup_dock = self._section_popups.get("setup") + if setup_dock is None: + return None + tabw = self._fixed_right_tab_widget + if tabw is None: + tabw = QtWidgets.QTabWidget() + tabw.setObjectName("post.fixed.right.tabs") + tabw.setDocumentMode(True) + tabw.setTabPosition(QtWidgets.QTabWidget.TabPosition.South) + self._fixed_right_tab_widget = tabw + for key in _FIXED_POST_RIGHT_TAB_ORDER: + scroll = self._section_scroll_hosts.get(key) + if scroll is None: + continue + idx = tabw.indexOf(scroll) + title = _FIXED_POST_RIGHT_TAB_TITLES.get(key, key.title()) + if idx < 0: + idx = tabw.addTab(scroll, title) + else: + tabw.setTabText(idx, title) + if setup_dock.widget() is not tabw: + setup_dock.setWidget(tabw) + return tabw + + def _apply_fixed_right_tabs_as_single_dock(self, host: QtWidgets.QMainWindow, active_key: str = "setup") -> None: + setup_dock = self._section_popups.get("setup") + if setup_dock is None: + return + tabw = self._ensure_fixed_right_tab_widget(host) + if tabw is None: + return + # Setup dock hosts the fixed tabs; the individual PSTH/Spatial docks stay hidden. + setup_dock.blockSignals(True) + try: + host.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, setup_dock) + setup_dock.setFloating(False) + setup_dock.show() + finally: + setup_dock.blockSignals(False) + for key in ("psth", "spatial", "signal", "behavior"): + dock = self._section_popups.get(key) + if dock is None: + continue + dock.blockSignals(True) + try: + host.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, dock) + dock.setFloating(False) + dock.hide() + finally: + dock.blockSignals(False) + active = active_key if active_key in _FIXED_POST_RIGHT_SECTIONS else "setup" + page = self._section_scroll_hosts.get(active) + idx = tabw.indexOf(page) if page is not None else -1 + if idx >= 0: + tabw.setCurrentIndex(idx) + try: + setup_dock.raise_() + setup_dock.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + except Exception: + pass + for key in _FIXED_POST_RIGHT_SECTIONS: + self._set_section_button_checked(key, True) + self._last_opened_section = active + + def _activate_fixed_right_tab(self, key: str) -> None: + host = self._dock_host or self._dock_main_window() + if host is None: + return + self._apply_fixed_right_tabs_as_single_dock(host, active_key=key) + + def _enforce_fixed_post_default_visibility(self) -> None: + if not self._force_fixed_default_layout: + return + if self._use_pg_dockarea_layout: + self._save_dockarea_layout_state() + return + host = self._dock_host or self._dock_main_window() + if host is None or not self._section_popups: + return + tabs = getattr(host, "tabs", None) + if isinstance(tabs, QtWidgets.QTabWidget) and tabs.currentWidget() is not self: + return + visible_keys = {"setup", "export"} + for key, dock in self._section_popups.items(): + if dock is None: + continue + dock.blockSignals(True) + try: + area = ( + QtCore.Qt.DockWidgetArea.BottomDockWidgetArea + if key == "export" + else QtCore.Qt.DockWidgetArea.RightDockWidgetArea + ) + host.addDockWidget(area, dock) + dock.setFloating(False) + if key in visible_keys: + dock.show() + else: + dock.hide() + finally: + dock.blockSignals(False) + self._apply_fixed_right_tabs_as_single_dock(host, active_key=self._last_opened_section or "setup") + setup = self._section_popups.get("setup") + if setup is not None: try: - pix = widget.grab() - pix.save(os.path.join(out_dir, f"{prefix}_{name}.png")) + setup.raise_() + setup.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + self._last_opened_section = "setup" except Exception: pass + try: + self._sync_section_button_states_from_docks() + except Exception: + pass + + def _persist_fixed_post_default_state(self) -> None: + if not self._force_fixed_default_layout: + return + if self._use_pg_dockarea_layout: + self._save_dockarea_layout_state() + return + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + bottom_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, 8) + for key in ("setup", "psth", "signal", "behavior", "spatial", "export"): + dock = self._section_popups.get(key) + if dock is None: + continue + base = f"post_section_docks/{key}" + visible = key in _FIXED_POST_VISIBLE_SECTIONS + area_i = bottom_i if key == "export" else right_i + try: + self._settings.setValue(f"{base}/visible", visible) + self._settings.setValue(f"{base}/floating", False) + self._settings.setValue(f"{base}/area", area_i) + self._settings.setValue(f"{base}/geometry", dock.saveGeometry()) + except Exception: + continue + # Fixed mode should not depend on snapshot restore blobs. + try: + self._settings.remove(_POST_DOCK_STATE_KEY) + except Exception: + pass + try: + self._settings.sync() + except Exception: + pass + + def apply_fixed_default_layout(self) -> None: + """ + Apply deterministic Post Processing docking default: + Setup, PSTH, Spatial, and Export as fixed right-side tabs. + """ + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + if not self._dock_layout_restored: + self._apply_fixed_dockarea_layout() + else: + self._apply_fixed_dock_features() + self._sync_section_button_states_from_docks() + self._save_dockarea_layout_state() + return + if self._applying_fixed_default_layout: + return + self._setup_section_popups() + host = self._dock_host or self._dock_main_window() + if host is None or not self._section_popups: + return + tabs = getattr(host, "tabs", None) + if isinstance(tabs, QtWidgets.QTabWidget) and tabs.currentWidget() is not self: + return + self._pending_fixed_layout_retry = False + self._dock_host = host + self._applying_fixed_default_layout = True + + setup = self._section_popups.get("setup") + ordered_right_keys = ["setup", "spatial", "psth", "signal", "behavior"] + visible_right_keys = {"setup"} + export = self._section_popups.get("export") + + self._suspend_panel_layout_persistence = True + try: + self._apply_fixed_dock_features() + # Reset post dock topology first so stale tab groups from previous + # sessions cannot override the enforced default. + for key in ("setup", "psth", "signal", "behavior", "spatial", "export"): + dock = self._section_popups.get(key) + if dock is None: + continue + try: + host.removeDockWidget(dock) + except Exception: + pass + + for key in ordered_right_keys: + dock = self._section_popups.get(key) + if dock is None: + continue + dock.blockSignals(True) + try: + host.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, dock) + dock.setFloating(False) + if key in visible_right_keys: + dock.show() + else: + dock.hide() + finally: + dock.blockSignals(False) + + if export is not None: + export.blockSignals(True) + try: + host.addDockWidget(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, export) + export.setFloating(False) + export.show() + finally: + export.blockSignals(False) + + self._apply_fixed_right_tabs_as_single_dock(host, active_key=self._last_opened_section or "setup") + + if setup is not None: + setup.show() + setup.raise_() + setup.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + self._last_opened_section = "setup" + if export is not None: + export.raise_() + + # Final hard enforcement: all post docks must be docked (non-floating). + for key in ("setup", "psth", "signal", "behavior", "spatial", "export"): + dock = self._section_popups.get(key) + if dock is None: + continue + try: + if dock.isFloating(): + dock.setFloating(False) + except Exception: + pass + + self._sync_section_button_states_from_docks() + self._post_docks_hidden_for_tab_switch = False + self._post_section_visibility_before_hide.clear() + self._post_section_state_before_hide.clear() + self._dock_layout_restored = True + finally: + self._suspend_panel_layout_persistence = False + self._applying_fixed_default_layout = False + + # Re-apply once after queued dock events for extra stability. + QtCore.QTimer.singleShot(0, self._enforce_fixed_post_default_visibility) + self._persist_fixed_post_default_state() + self._enforce_only_post_docks_visible() + + def ensure_section_popups_initialized(self) -> None: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + return + self._setup_section_popups() + + def get_section_dock_widgets(self) -> List[QtWidgets.QDockWidget]: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + return [] + self._setup_section_popups() + return list(self._section_popups.values()) + + def get_section_popup_keys(self) -> List[str]: + if self._use_pg_dockarea_layout: + self._setup_dockarea_sections() + return list(self._dockarea_docks.keys()) or list(self._section_widget_map().keys()) + self._setup_section_popups() + return list(self._section_popups.keys()) + + def mark_dock_layout_restored(self) -> None: + self._dock_layout_restored = True class ExportDialog(QtWidgets.QDialog): @@ -2318,6 +8187,56 @@ def choices(self) -> Dict[str, bool]: } +class ExportImageDialog(QtWidgets.QDialog): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Export Images") + self.setModal(True) + layout = QtWidgets.QVBoxLayout(self) + + self.cb_all = QtWidgets.QCheckBox("All panels") + self.cb_trace = QtWidgets.QCheckBox("Trace preview") + self.cb_heat = QtWidgets.QCheckBox("Heatmap + durations") + self.cb_avg = QtWidgets.QCheckBox("Average + metrics") + self.cb_signal = QtWidgets.QCheckBox("Signal analyzer") + self.cb_behavior = QtWidgets.QCheckBox("Behavior analysis") + self.cb_spatial = QtWidgets.QCheckBox("Spatial window") + + self.cb_all.setChecked(True) + for cb in (self.cb_all, self.cb_trace, self.cb_heat, self.cb_avg, self.cb_signal, self.cb_behavior, self.cb_spatial): + layout.addWidget(cb) + self._set_individual_enabled(self.cb_all.isChecked()) + + row = QtWidgets.QHBoxLayout() + row.addStretch(1) + btn_ok = QtWidgets.QPushButton("OK") + btn_cancel = QtWidgets.QPushButton("Cancel") + btn_ok.setDefault(True) + row.addWidget(btn_ok) + row.addWidget(btn_cancel) + layout.addLayout(row) + + self.cb_all.toggled.connect(self._set_individual_enabled) + btn_ok.clicked.connect(self.accept) + btn_cancel.clicked.connect(self.reject) + + def _set_individual_enabled(self, all_checked: bool) -> None: + enabled = not bool(all_checked) + for cb in (self.cb_trace, self.cb_heat, self.cb_avg, self.cb_signal, self.cb_behavior, self.cb_spatial): + cb.setEnabled(enabled) + + def choices(self) -> Dict[str, bool]: + return { + "all": self.cb_all.isChecked(), + "trace": self.cb_trace.isChecked(), + "heat": self.cb_heat.isChecked(), + "avg": self.cb_avg.isChecked(), + "signal": self.cb_signal.isChecked(), + "behavior": self.cb_behavior.isChecked(), + "spatial": self.cb_spatial.isChecked(), + } + + class StyleDialog(QtWidgets.QDialog): def __init__(self, style: Dict[str, object], parent=None) -> None: super().__init__(parent) @@ -2330,6 +8249,19 @@ def __init__(self, style: Dict[str, object], parent=None) -> None: self.btn_trace = QtWidgets.QPushButton("Pick") self.btn_behavior = QtWidgets.QPushButton("Pick") self.btn_avg = QtWidgets.QPushButton("Pick") + self.btn_sem_edge = QtWidgets.QPushButton("Pick") + self.btn_sem_fill = QtWidgets.QPushButton("Pick") + self.btn_plot_bg = QtWidgets.QPushButton("Pick") + self.cb_grid = QtWidgets.QCheckBox("Show grid on plots") + self.cb_grid.setChecked(bool(self._style.get("grid_enabled", True))) + self.spin_grid_alpha = QtWidgets.QDoubleSpinBox() + self.spin_grid_alpha.setRange(0.0, 1.0) + self.spin_grid_alpha.setSingleStep(0.05) + self.spin_grid_alpha.setDecimals(2) + try: + self.spin_grid_alpha.setValue(float(self._style.get("grid_alpha", 0.25))) + except Exception: + self.spin_grid_alpha.setValue(0.25) self.combo_cmap = QtWidgets.QComboBox() self.combo_cmap.addItems(["viridis", "plasma", "inferno", "magma", "cividis", "turbo", "gray"]) if self._style.get("heatmap_cmap"): @@ -2345,6 +8277,11 @@ def __init__(self, style: Dict[str, object], parent=None) -> None: layout.addRow("Trace color", self.btn_trace) layout.addRow("Behavior color", self.btn_behavior) layout.addRow("Avg color", self.btn_avg) + layout.addRow("SEM edge color", self.btn_sem_edge) + layout.addRow("SEM fill color", self.btn_sem_fill) + layout.addRow("Plot background", self.btn_plot_bg) + layout.addRow("Grid", self.cb_grid) + layout.addRow("Grid alpha", self.spin_grid_alpha) layout.addRow("Heatmap colormap", self.combo_cmap) layout.addRow("Heatmap min", self.spin_hmin) layout.addRow("Heatmap max", self.spin_hmax) @@ -2361,17 +8298,51 @@ def __init__(self, style: Dict[str, object], parent=None) -> None: self.btn_trace.clicked.connect(lambda *_: self._pick_color("trace")) self.btn_behavior.clicked.connect(lambda *_: self._pick_color("behavior")) self.btn_avg.clicked.connect(lambda *_: self._pick_color("avg")) + self.btn_sem_edge.clicked.connect(lambda *_: self._pick_color("sem_edge")) + self.btn_sem_fill.clicked.connect(lambda *_: self._pick_color("sem_fill", with_alpha=True)) + self.btn_plot_bg.clicked.connect(lambda *_: self._pick_color("plot_bg")) btn_ok.clicked.connect(self.accept) btn_cancel.clicked.connect(self.reject) - def _pick_color(self, key: str) -> None: - col = QtWidgets.QColorDialog.getColor(parent=self) + def _pick_color(self, key: str, with_alpha: bool = False) -> None: + current = self._style.get(key, (255, 255, 255, 255) if with_alpha else (255, 255, 255)) + if isinstance(current, np.ndarray): + current_vals = current.tolist() + elif isinstance(current, (list, tuple)): + current_vals = list(current) + else: + current_vals = [255, 255, 255, 255] if with_alpha else [255, 255, 255] + if with_alpha: + while len(current_vals) < 4: + current_vals.append(255) + qcol = QtGui.QColor( + int(current_vals[0]), + int(current_vals[1]), + int(current_vals[2]), + int(current_vals[3]), + ) + else: + qcol = QtGui.QColor( + int(current_vals[0]) if len(current_vals) > 0 else 255, + int(current_vals[1]) if len(current_vals) > 1 else 255, + int(current_vals[2]) if len(current_vals) > 2 else 255, + ) + options = QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel if with_alpha else QtWidgets.QColorDialog.ColorDialogOption(0) + col = QtWidgets.QColorDialog.getColor(qcol, self, "Select color", options) if not col.isValid(): return - self._style[key] = (col.red(), col.green(), col.blue()) + if with_alpha: + self._style[key] = (col.red(), col.green(), col.blue(), col.alpha()) + else: + self._style[key] = (col.red(), col.green(), col.blue()) def get_style(self) -> Dict[str, object]: self._style["heatmap_cmap"] = self.combo_cmap.currentText() self._style["heatmap_min"] = float(self.spin_hmin.value()) if self.spin_hmin.value() != 0.0 else None self._style["heatmap_max"] = float(self.spin_hmax.value()) if self.spin_hmax.value() != 0.0 else None + self._style["grid_enabled"] = bool(self.cb_grid.isChecked()) + self._style["grid_alpha"] = float(self.spin_grid_alpha.value()) + self._style.setdefault("plot_bg", (36, 42, 52)) + self._style.setdefault("sem_edge", (152, 201, 143)) + self._style.setdefault("sem_fill", (188, 230, 178, 96)) return dict(self._style) diff --git a/pyBer/gui_preprocessing.py b/pyBer/gui_preprocessing.py index 9fe0341..86ed934 100644 --- a/pyBer/gui_preprocessing.py +++ b/pyBer/gui_preprocessing.py @@ -10,11 +10,13 @@ import pyqtgraph as pg from analysis_core import ( + ExportSelection, ProcessingParams, ProcessedTrial, OUTPUT_MODES, BASELINE_METHODS, REFERENCE_FIT_METHODS, + SMOOTHING_METHODS, ) @@ -67,6 +69,7 @@ def _compact_combo(combo: QtWidgets.QComboBox, min_chars: int = 6) -> None: "zscore (subtractions)": "z = zscore(dFF_sig) - zscore(dFF_ref)", "dFF (motion corrected with fitted ref)": "dFF = (sig_f - fitted_ref) / fitted_ref", "zscore (motion corrected with fitted ref)": "z = zscore((sig_f - fitted_ref) / fitted_ref)", + "Raw signal (465)": "output = filtered/resampled 465 signal", } @@ -96,6 +99,164 @@ def _parse_float_text(text: str) -> Optional[float]: return None +class PlaceholderListWidget(QtWidgets.QListWidget): + def __init__(self, placeholder_text: str = "", parent=None) -> None: + super().__init__(parent) + self._placeholder_text = placeholder_text + + def setPlaceholderText(self, text: str) -> None: + self._placeholder_text = str(text or "") + self.viewport().update() + + def paintEvent(self, event) -> None: + super().paintEvent(event) + if self.count() > 0 or not self._placeholder_text: + return + p = QtGui.QPainter(self.viewport()) + p.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True) + color = self.palette().color(QtGui.QPalette.ColorRole.Text) + color.setAlpha(110) + p.setPen(color) + rect = self.viewport().rect().adjusted(12, 12, -12, -12) + p.drawText( + rect, + QtCore.Qt.AlignmentFlag.AlignTop | QtCore.Qt.AlignmentFlag.AlignHCenter | QtCore.Qt.TextFlag.TextWordWrap, + self._placeholder_text, + ) + p.end() + + +class CheckableListWidget(QtWidgets.QListWidget): + changed = QtCore.Signal() + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection) + self.itemChanged.connect(lambda *_: self.changed.emit()) + + def set_items(self, items: List[Tuple[str, str]], checked_values: Optional[List[str]] = None) -> None: + selected = {str(v) for v in (checked_values or []) if str(v)} + self.blockSignals(True) + try: + self.clear() + for label, value in items or []: + item = QtWidgets.QListWidgetItem(str(label)) + item.setData(QtCore.Qt.ItemDataRole.UserRole, str(value)) + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable | QtCore.Qt.ItemFlag.ItemIsEnabled) + item.setCheckState( + QtCore.Qt.CheckState.Checked if str(value) in selected else QtCore.Qt.CheckState.Unchecked + ) + self.addItem(item) + finally: + self.blockSignals(False) + + def checked_values(self) -> List[str]: + values: List[str] = [] + for i in range(self.count()): + item = self.item(i) + if item is None: + continue + if item.checkState() == QtCore.Qt.CheckState.Checked: + values.append(str(item.data(QtCore.Qt.ItemDataRole.UserRole) or "").strip()) + return [v for v in values if v] + + def set_checked_values(self, values: List[str]) -> None: + selected = {str(v) for v in values or [] if str(v)} + self.blockSignals(True) + try: + for i in range(self.count()): + item = self.item(i) + if item is None: + continue + value = str(item.data(QtCore.Qt.ItemDataRole.UserRole) or "").strip() + item.setCheckState( + QtCore.Qt.CheckState.Checked if value in selected else QtCore.Qt.CheckState.Unchecked + ) + finally: + self.blockSignals(False) + + +class CollapsibleSection(QtWidgets.QWidget): + toggled = QtCore.Signal(bool) + + def __init__(self, title: str, parent=None) -> None: + super().__init__(parent) + self._title = title + self._expanded = True + self._build_ui() + + def _build_ui(self) -> None: + root = QtWidgets.QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(4) + + self.btn_toggle = QtWidgets.QToolButton() + self.btn_toggle.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.btn_toggle.setArrowType(QtCore.Qt.ArrowType.DownArrow) + self.btn_toggle.setCheckable(True) + self.btn_toggle.setChecked(True) + self.btn_toggle.setText(self._title) + self.btn_toggle.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_toggle.clicked.connect(self._on_toggle) + + self.lbl_summary = QtWidgets.QLabel("") + self.lbl_summary.setProperty("class", "hint") + self.lbl_summary.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) + self.lbl_summary.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + + header = QtWidgets.QHBoxLayout() + header.setContentsMargins(0, 0, 0, 0) + header.setSpacing(8) + header.addWidget(self.btn_toggle, stretch=1) + header.addWidget(self.lbl_summary, stretch=1) + + self.content = QtWidgets.QWidget() + self.content_layout = QtWidgets.QVBoxLayout(self.content) + self.content_layout.setContentsMargins(8, 8, 8, 8) + self.content_layout.setSpacing(8) + + frame = QtWidgets.QFrame() + frame.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel) + frame_layout = QtWidgets.QVBoxLayout(frame) + frame_layout.setContentsMargins(0, 0, 0, 0) + frame_layout.setSpacing(0) + frame_layout.addWidget(self.content) + self.frame = frame + + root.addLayout(header) + root.addWidget(frame) + + def set_summary(self, text: str) -> None: + self.lbl_summary.setText(str(text or "")) + + def set_content_widget(self, widget: QtWidgets.QWidget) -> None: + while self.content_layout.count(): + item = self.content_layout.takeAt(0) + w = item.widget() + if w is not None: + w.setParent(None) + self.content_layout.addWidget(widget) + + def is_expanded(self) -> bool: + return self._expanded + + def set_expanded(self, expanded: bool) -> None: + exp = bool(expanded) + self._expanded = exp + self.btn_toggle.setChecked(exp) + self.btn_toggle.setArrowType(QtCore.Qt.ArrowType.DownArrow if exp else QtCore.Qt.ArrowType.RightArrow) + self.frame.setVisible(exp) + self.lbl_summary.setVisible(not exp) + self.toggled.emit(exp) + + def _on_toggle(self, checked: bool) -> None: + self._expanded = bool(checked) + self.btn_toggle.setArrowType(QtCore.Qt.ArrowType.DownArrow if checked else QtCore.Qt.ArrowType.RightArrow) + self.frame.setVisible(bool(checked)) + self.lbl_summary.setVisible(not bool(checked)) + self.toggled.emit(bool(checked)) + + # ----------------------------- Metadata dialog ----------------------------- class MetadataForm(QtWidgets.QWidget): @@ -647,39 +808,37 @@ def __init__(self, parent=None) -> None: def _build_ui(self) -> None: v = QtWidgets.QVBoxLayout(self) - v.setSpacing(10) + v.setSpacing(8) + v.setContentsMargins(8, 8, 8, 8) - row = QtWidgets.QVBoxLayout() - row.setSpacing(4) + # Top actions + top_row = QtWidgets.QHBoxLayout() self.btn_open = QtWidgets.QPushButton("Open File") self.btn_folder = QtWidgets.QPushButton("Add Folder") - self.btn_open.setProperty("class", "compactSmall") - self.btn_folder.setProperty("class", "compactSmall") - self.btn_open.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) - self.btn_folder.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) - row.addWidget(self.btn_open) - row.addWidget(self.btn_folder) - - # File list with remove button - file_layout = QtWidgets.QVBoxLayout() - self.list_files = QtWidgets.QListWidget() + self.btn_open.setProperty("class", "bluePrimarySmall") + self.btn_folder.setProperty("class", "blueSecondarySmall") + for b in (self.btn_open, self.btn_folder): + b.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + top_row.addWidget(self.btn_open) + top_row.addWidget(self.btn_folder) + + # File list fills available height + self.list_files = PlaceholderListWidget("Drop files here or click Open File") self.list_files.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - self.list_files.setMinimumHeight(110) + self.list_files.setMinimumHeight(180) self.btn_remove_file = QtWidgets.QPushButton("Remove selected") - self.btn_remove_file.setProperty("class", "compactSmall") - self.btn_remove_file.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_remove_file.setProperty("class", "blueSecondarySmall") + self.btn_remove_file.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) self.btn_remove_file.setEnabled(False) self.btn_remove_file.clicked.connect(self._remove_selected_files) - file_layout.addWidget(self.list_files) - file_layout.addWidget(self.btn_remove_file) - + # Selection block self.grp_sel = QtWidgets.QGroupBox("Selection") - form = QtWidgets.QFormLayout(self.grp_sel) - form.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) - form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) - form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + form = QtWidgets.QGridLayout(self.grp_sel) + form.setContentsMargins(8, 8, 8, 8) + form.setHorizontalSpacing(6) + form.setVerticalSpacing(6) self.combo_channel = QtWidgets.QComboBox() self.combo_channel.setMinimumWidth(60) @@ -694,73 +853,55 @@ def _build_ui(self) -> None: self.edit_time_end = QtWidgets.QLineEdit() for ed in (self.edit_time_start, self.edit_time_end): ed.setPlaceholderText("Start (s)" if ed is self.edit_time_start else "End (s)") - ed.setMinimumWidth(70) val = QtGui.QDoubleValidator(0.0, 1e9, 3, ed) val.setLocale(_system_locale()) ed.setValidator(val) - ed.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Fixed) - - time_row = QtWidgets.QGridLayout() - time_row.setHorizontalSpacing(6) - time_row.setContentsMargins(0, 0, 0, 0) - lbl_start = QtWidgets.QLabel("Start:") - lbl_end = QtWidgets.QLabel("End:") - lbl_start.setMinimumWidth(45) - lbl_end.setMinimumWidth(35) - time_row.addWidget(lbl_start, 0, 0) - time_row.addWidget(self.edit_time_start, 0, 1) - time_row.addWidget(lbl_end, 0, 2) - time_row.addWidget(self.edit_time_end, 0, 3) - time_row.setColumnStretch(1, 1) - time_row.setColumnStretch(3, 1) - time_widget = QtWidgets.QWidget() - time_widget.setLayout(time_row) - - form.addRow("Channel (preview)", self.combo_channel) - form.addRow("Digital trigger (overlay)", self.combo_trigger) - form.addRow("Time window (s)", time_widget) - - btncol = QtWidgets.QVBoxLayout() - btncol.setSpacing(6) - - self.btn_metadata = QtWidgets.QPushButton("Metadata") - self.btn_update = QtWidgets.QPushButton("Update") - self.btn_artifacts = QtWidgets.QPushButton("Artifacts?") - self.btn_export = QtWidgets.QPushButton("Export CSV/H5?") - - self.btn_advanced = QtWidgets.QPushButton("Advanced options") - self.btn_qc = QtWidgets.QPushButton("Quality check") - self.btn_qc_batch = QtWidgets.QPushButton("Batch quality metrics") - - for b in ( - self.btn_metadata, self.btn_update, self.btn_artifacts, self.btn_export, - self.btn_advanced, self.btn_qc, self.btn_qc_batch, - ): - b.setProperty("class", "compactSmall") - b.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, - QtWidgets.QSizePolicy.Policy.Fixed) - - self.btn_update.setProperty("class", "compactPrimarySmall") - self.btn_export.setProperty("class", "compactPrimarySmall") - - btncol.addWidget(self.btn_metadata) - btncol.addWidget(self.btn_update) - btncol.addWidget(self.btn_artifacts) - btncol.addWidget(self.btn_export) - btncol.addWidget(self.btn_advanced) - btncol.addWidget(self.btn_qc) - btncol.addWidget(self.btn_qc_batch) + ed.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + + form.addWidget(QtWidgets.QLabel("Channel"), 0, 0) + form.addWidget(self.combo_channel, 0, 1, 1, 3) + form.addWidget(QtWidgets.QLabel("Analog/Digital channel"), 1, 0) + form.addWidget(self.combo_trigger, 1, 1, 1, 3) + form.addWidget(QtWidgets.QLabel("Time window"), 2, 0) + form.addWidget(self.edit_time_start, 2, 1) + form.addWidget(QtWidgets.QLabel("to"), 2, 2) + form.addWidget(self.edit_time_end, 2, 3) + self.btn_cutting = QtWidgets.QPushButton("Cutting / Sectioning") + self.btn_cutting.setProperty("class", "blueSecondarySmall") + self.btn_cutting.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + form.addWidget(self.btn_cutting, 3, 0, 1, 4) + form.setColumnStretch(1, 1) + form.setColumnStretch(3, 1) self.lbl_hint = QtWidgets.QLabel("") self.lbl_hint.setProperty("class", "hint") self.lbl_hint.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) - v.addLayout(row) - v.addLayout(file_layout) + v.addLayout(top_row) + v.addWidget(self.list_files, stretch=1) + v.addWidget(self.btn_remove_file) v.addWidget(self.grp_sel) - v.addLayout(btncol) v.addWidget(self.lbl_hint) + # Legacy action buttons removed from layout; keep hidden buttons for compatibility. + self.btn_metadata = QtWidgets.QPushButton("Metadata") + self.btn_update = QtWidgets.QPushButton("Update") + self.btn_artifacts = QtWidgets.QPushButton("Artifacts") + self.btn_export = QtWidgets.QPushButton("Export") + self.btn_advanced = QtWidgets.QPushButton("Cutting / Sectioning") + self.btn_qc = QtWidgets.QPushButton("Quality check") + self.btn_qc_batch = QtWidgets.QPushButton("Batch quality metrics") + for b in ( + self.btn_metadata, + self.btn_update, + self.btn_artifacts, + self.btn_export, + self.btn_advanced, + self.btn_qc, + self.btn_qc_batch, + ): + b.setVisible(False) + self.btn_open.clicked.connect(self.openFileRequested.emit) self.btn_folder.clicked.connect(self.openFolderRequested.emit) self.list_files.itemSelectionChanged.connect(self.selectionChanged.emit) @@ -776,6 +917,7 @@ def _build_ui(self) -> None: self.btn_export.clicked.connect(self.exportRequested.emit) self.btn_artifacts.clicked.connect(self.toggleArtifactsRequested.emit) self.btn_advanced.clicked.connect(self.advancedOptionsRequested.emit) + self.btn_cutting.clicked.connect(self.advancedOptionsRequested.emit) self.btn_qc.clicked.connect(self.qcRequested.emit) self.btn_qc_batch.clicked.connect(self.batchQcRequested.emit) @@ -796,7 +938,10 @@ def current_dir_hint(self) -> str: return self._current_dir_hint def add_file(self, path: str) -> None: - self.list_files.addItem(path) + item = QtWidgets.QListWidgetItem(os.path.basename(path)) + item.setToolTip(path) + item.setData(QtCore.Qt.ItemDataRole.UserRole, path) + self.list_files.addItem(item) if self.list_files.count() == 1: self.list_files.setCurrentRow(0) item0 = self.list_files.item(0) @@ -810,10 +955,21 @@ def add_file(self, path: str) -> None: pass def all_paths(self) -> List[str]: - return [self.list_files.item(i).text() for i in range(self.list_files.count())] + out: List[str] = [] + for i in range(self.list_files.count()): + item = self.list_files.item(i) + if item is None: + continue + path = item.data(QtCore.Qt.ItemDataRole.UserRole) + out.append(str(path if path else item.text())) + return out def selected_paths(self) -> List[str]: - return [it.text() for it in self.list_files.selectedItems()] + out: List[str] = [] + for it in self.list_files.selectedItems(): + path = it.data(QtCore.Qt.ItemDataRole.UserRole) + out.append(str(path if path else it.text())) + return out def set_available_channels(self, chans: List[str]) -> None: self.combo_channel.blockSignals(True) @@ -1157,10 +1313,20 @@ def get_sections(self) -> List[Dict[str, object]]: class ParameterPanel(QtWidgets.QGroupBox): paramsChanged = QtCore.Signal() + metadataRequested = QtCore.Signal() + previewRequested = QtCore.Signal() + artifactsRequested = QtCore.Signal() + artifactOverlayToggled = QtCore.Signal(bool) + exportRequested = QtCore.Signal() + advancedOptionsRequested = QtCore.Signal() + qcRequested = QtCore.Signal() + batchQcRequested = QtCore.Signal() def __init__(self, parent=None) -> None: super().__init__("Processing Parameters", parent) self._help_texts = self._build_help_texts() + self._config_state_exporter: Optional[Callable[[], Dict[str, object]]] = None + self._config_state_importer: Optional[Callable[[Dict[str, object]], None]] = None self._build_ui() self._wire() @@ -1194,6 +1360,24 @@ def _build_help_texts(self) -> Dict[str, str]: "Target sampling rate (Hz) for decimation.\n" "Lower values speed processing and plotting but reduce time resolution." ), + "smoothing_enabled": ( + "Optional post-filter smoothing stage on the processed timebase.\n" + "Use it to reduce residual high-frequency noise after low-pass/resampling." + ), + "smoothing_method": ( + "Smoothing algorithm:\n" + "- Savitzky-Golay: polynomial local fit, preserves peak shapes.\n" + "- Moving average: uniform sliding mean.\n" + "- Moving median: robust to spikes and impulsive noise." + ), + "smoothing_window_s": ( + "Smoothing window in seconds.\n" + "Larger windows produce stronger smoothing but can attenuate fast transients." + ), + "smoothing_polyorder": ( + "Polynomial order for Savitzky-Golay smoothing.\n" + "Lower order is smoother; higher order follows curvature more closely." + ), "baseline_method": ( "Baseline method (pybaselines):\n" "- asls: asymmetric least squares; uses p to favor baseline below peaks.\n" @@ -1251,6 +1435,22 @@ def _build_help_texts(self) -> Dict[str, str]: "Convergence tolerance for robust regression.\n" "Smaller values are stricter but may take more iterations." ), + "export_selection": ( + "Choose which processed signals are written during CSV/H5 export.\n" + "Time is always exported. These options are saved in the preprocessing configuration file." + ), + "export_dio_channel": ( + "Choose which DIO / trigger channel to export.\n" + "If left on Current overlay, export uses the trigger currently selected in the file panel." + ), + "export_analog_channels": ( + "Choose one or more analog channels to export.\n" + "If none are checked, export uses the current preview channel." + ), + "export_dio_channels": ( + "Choose one or more DIO channels to export.\n" + "If none are checked, export uses the current overlay trigger." + ), } def _show_help(self, key: str, title: str) -> None: @@ -1274,10 +1474,9 @@ def _label_with_help(self, text: str, key: str) -> QtWidgets.QWidget: return w def _build_ui(self) -> None: - form = QtWidgets.QFormLayout(self) - form.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) - form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) - form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + root = QtWidgets.QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(8) def mk_dspin(minw=60, decimals=3) -> QtWidgets.QDoubleSpinBox: s = QtWidgets.QDoubleSpinBox() @@ -1294,139 +1493,134 @@ def mk_spin(minw=60) -> QtWidgets.QSpinBox: s.setKeyboardTracking(False) return s - # Artifact detection toggle and dropdown - artifact_row = QtWidgets.QHBoxLayout() + # Artifacts controls self.cb_artifact = QtWidgets.QCheckBox("Enable artifact detection") self.cb_artifact.setChecked(True) + self.cb_show_artifact_overlay = QtWidgets.QCheckBox("Show artifact detection overlay") + self.cb_show_artifact_overlay.setChecked(True) + self.cb_show_artifact_overlay.setToolTip( + "Toggle detected artifact interval overlays on the raw plot." + ) self.combo_artifact = QtWidgets.QComboBox() self.combo_artifact.addItems(["Global MAD (dx)", "Adaptive MAD (windowed)"]) _compact_combo(self.combo_artifact, min_chars=6) - artifact_row.addWidget(self.cb_artifact) - artifact_row.addWidget(self.combo_artifact, stretch=1) - artifact_widget = QtWidgets.QWidget() - artifact_widget.setLayout(artifact_row) - self.spin_mad = mk_dspin() self.spin_mad.setRange(1.0, 50.0) self.spin_mad.setValue(8.0) - self.spin_adapt_win = mk_dspin() self.spin_adapt_win.setRange(0.2, 60.0) self.spin_adapt_win.setValue(5.0) - self.spin_pad = mk_dspin() self.spin_pad.setRange(0.0, 10.0) self.spin_pad.setValue(0.25) - # Filtering toggle and dropdown - filter_row = QtWidgets.QHBoxLayout() + art_content = QtWidgets.QWidget() + art_form = QtWidgets.QFormLayout(art_content) + art_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + art_form.addRow(self.cb_artifact) + art_form.addRow(self.cb_show_artifact_overlay) + art_form.addRow(self._label_with_help("Method", "artifact_mode"), self.combo_artifact) + art_form.addRow(self._label_with_help("MAD threshold (k)", "mad_k"), self.spin_mad) + art_form.addRow(self._label_with_help("Adaptive window (s)", "adaptive_window_s"), self.spin_adapt_win) + art_form.addRow(self._label_with_help("Artifact pad (s)", "artifact_pad_s"), self.spin_pad) + + # Filtering controls self.cb_filtering = QtWidgets.QCheckBox("Enable filtering") self.cb_filtering.setChecked(True) - filter_row.addWidget(self.cb_filtering) - filter_widget = QtWidgets.QWidget() - filter_widget.setLayout(filter_row) - self.spin_lowpass = mk_dspin() self.spin_lowpass.setRange(0.1, 200.0) self.spin_lowpass.setValue(12.0) - self.spin_filt_order = mk_spin() self.spin_filt_order.setRange(1, 8) self.spin_filt_order.setValue(3) - self.spin_target_fs = mk_dspin(decimals=1) self.spin_target_fs.setRange(1.0, 1000.0) self.spin_target_fs.setValue(100.0) - + self.cb_smoothing = QtWidgets.QCheckBox("Enable smoothing") + self.cb_smoothing.setChecked(False) + self.combo_smoothing = QtWidgets.QComboBox() + self.combo_smoothing.addItems(SMOOTHING_METHODS) + _compact_combo(self.combo_smoothing, min_chars=8) + self.spin_smoothing_window = mk_dspin(decimals=3) + self.spin_smoothing_window.setRange(0.001, 60.0) + self.spin_smoothing_window.setValue(0.200) + self.spin_smoothing_poly = mk_spin() + self.spin_smoothing_poly.setRange(1, 7) + self.spin_smoothing_poly.setValue(2) self.cb_invert = QtWidgets.QCheckBox("Invert signal polarity (465/405)") self.cb_invert.setChecked(False) self.cb_invert.setToolTip( "Flips both raw channels before artifact detection, filtering, baseline, and output computation." ) - # Baseline method and lambda (main parameters) - baseline_main_row = QtWidgets.QHBoxLayout() + filt_content = QtWidgets.QWidget() + filt_form = QtWidgets.QFormLayout(filt_content) + filt_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + filt_form.addRow(self.cb_filtering) + filt_form.addRow(self._label_with_help("Low-pass cutoff (Hz)", "lowpass_hz"), self.spin_lowpass) + filt_form.addRow(self._label_with_help("Filter order", "filter_order"), self.spin_filt_order) + filt_form.addRow(self._label_with_help("Target FS (Hz)", "target_fs_hz"), self.spin_target_fs) + filt_form.addRow(self._label_with_help("Enable smoothing", "smoothing_enabled"), self.cb_smoothing) + filt_form.addRow(self._label_with_help("Smoothing method", "smoothing_method"), self.combo_smoothing) + filt_form.addRow(self._label_with_help("Smoothing window (s)", "smoothing_window_s"), self.spin_smoothing_window) + filt_form.addRow(self._label_with_help("Savitzky polyorder", "smoothing_polyorder"), self.spin_smoothing_poly) + filt_form.addRow(self._label_with_help("Invert signal polarity", "invert_polarity"), self.cb_invert) + + # Baseline controls self.combo_baseline = QtWidgets.QComboBox() self.combo_baseline.addItems([m for m in BASELINE_METHODS]) _compact_combo(self.combo_baseline, min_chars=6) - - lam_row = QtWidgets.QHBoxLayout() - lam_row.setSpacing(6) - self.spin_lam_x = mk_dspin(minw=90, decimals=3) self.spin_lam_x.setRange(0.1, 9.999) self.spin_lam_x.setValue(1.0) - - self.lbl_e = QtWidgets.QLabel("e") - self.lbl_e.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.spin_lam_y = mk_spin(minw=80) self.spin_lam_y.setRange(-3, 12) self.spin_lam_y.setValue(9) - self.lbl_lam_preview = QtWidgets.QLabel("= 1e9") self.lbl_lam_preview.setProperty("class", "hint") - - lam_row.addWidget(self.spin_lam_x) - lam_row.addWidget(self.lbl_e) - lam_row.addWidget(self.spin_lam_y) - lam_row.addWidget(self.lbl_lam_preview, stretch=1) - - lam_widget = QtWidgets.QWidget() - lam_widget.setLayout(lam_row) - - baseline_main_row.addWidget(QtWidgets.QLabel("Method:")) - baseline_main_row.addWidget(self.combo_baseline) - baseline_main_row.addWidget(QtWidgets.QLabel("Lambda:")) - baseline_main_row.addWidget(lam_widget, stretch=1) - baseline_main_widget = QtWidgets.QWidget() - baseline_main_widget.setLayout(baseline_main_row) - - # Baseline advanced parameters (hidden by default) - self.baseline_advanced_group = QtWidgets.QGroupBox("Baseline advanced parameters") - self.baseline_advanced_group.setCheckable(True) - self.baseline_advanced_group.setChecked(False) - self.baseline_advanced_group.setVisible(False) # Hidden by default - baseline_form = QtWidgets.QFormLayout(self.baseline_advanced_group) - baseline_form.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) - baseline_form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) - self.spin_diff = mk_spin() self.spin_diff.setRange(1, 3) self.spin_diff.setValue(2) - self.spin_iter = mk_spin() self.spin_iter.setRange(1, 200) self.spin_iter.setValue(50) - self.spin_tol = mk_dspin(decimals=6) self.spin_tol.setRange(1e-8, 1e-1) self.spin_tol.setValue(1e-3) - self.spin_asls_p = mk_dspin(decimals=4) self.spin_asls_p.setRange(0.001, 0.5) self.spin_asls_p.setValue(0.01) - baseline_form.addRow(self._label_with_help("diff_order", "baseline_diff_order"), self.spin_diff) - baseline_form.addRow(self._label_with_help("max_iter", "baseline_max_iter"), self.spin_iter) - baseline_form.addRow(self._label_with_help("tol", "baseline_tol"), self.spin_tol) - baseline_form.addRow(self._label_with_help("AsLS p", "asls_p"), self.spin_asls_p) + lam_row = QtWidgets.QHBoxLayout() + lam_row.setSpacing(6) + lam_row.addWidget(self.spin_lam_x) + lam_row.addWidget(QtWidgets.QLabel("e")) + lam_row.addWidget(self.spin_lam_y) + lam_row.addWidget(self.lbl_lam_preview, stretch=1) + lam_widget = QtWidgets.QWidget() + lam_widget.setLayout(lam_row) + + self.baseline_advanced_group = QtWidgets.QGroupBox("Advanced parameters") + self.baseline_advanced_group.setVisible(False) + baseline_adv_form = QtWidgets.QFormLayout(self.baseline_advanced_group) + baseline_adv_form.addRow(self._label_with_help("diff_order", "baseline_diff_order"), self.spin_diff) + baseline_adv_form.addRow(self._label_with_help("max_iter", "baseline_max_iter"), self.spin_iter) + baseline_adv_form.addRow(self._label_with_help("tol", "baseline_tol"), self.spin_tol) + baseline_adv_form.addRow(self._label_with_help("AsLS p", "asls_p"), self.spin_asls_p) - # Button to show/hide baseline advanced parameters self.btn_toggle_advanced = QtWidgets.QPushButton("Show advanced baseline options") self.btn_toggle_advanced.setProperty("class", "compactSmall") self.btn_toggle_advanced.clicked.connect(self._toggle_advanced_baseline) - self.output_group = QtWidgets.QGroupBox("Output") - out_v = QtWidgets.QVBoxLayout(self.output_group) - out_v.setContentsMargins(6, 6, 6, 6) - out_v.setSpacing(6) - - out_form = QtWidgets.QFormLayout() - out_form.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) - out_form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop) - out_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + base_content = QtWidgets.QWidget() + base_form = QtWidgets.QFormLayout(base_content) + base_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + base_form.addRow(self._label_with_help("Method", "baseline_method"), self.combo_baseline) + base_form.addRow(self._label_with_help("Lambda", "baseline_lambda"), lam_widget) + base_form.addRow(self.btn_toggle_advanced) + base_form.addRow(self.baseline_advanced_group) + # Output controls self.combo_output = QtWidgets.QComboBox() self.combo_output.addItems(OUTPUT_MODES) _compact_combo(self.combo_output, min_chars=8) @@ -1437,105 +1631,181 @@ def mk_spin(minw=60) -> QtWidgets.QSpinBox: if mode == "zscore (subtractions)": tip = "Difference of z-scored channels; not the same as zscore of the difference." self.combo_output.setItemData(i, tip, QtCore.Qt.ItemDataRole.ToolTipRole) - self.ed_output_definition = QtWidgets.QLineEdit() self.ed_output_definition.setReadOnly(True) self.ed_output_definition.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) self.ed_output_definition.setPlaceholderText("Definition updates with output mode") - self.combo_ref_fit = QtWidgets.QComboBox() self.combo_ref_fit.addItems(REFERENCE_FIT_METHODS) _compact_combo(self.combo_ref_fit, min_chars=6) self.combo_ref_fit.setToolTip("Used only for fitted-reference motion correction modes.") - self.spin_lasso = mk_dspin(decimals=6) self.spin_lasso.setRange(1e-6, 1.0) self.spin_lasso.setValue(1e-3) self.spin_lasso.setToolTip( "Higher alpha means stronger shrinkage; if sklearn is missing, Lasso falls back to OLS." ) - self.spin_rlm_huber_t = mk_dspin(decimals=3) self.spin_rlm_huber_t.setRange(0.1, 10.0) self.spin_rlm_huber_t.setValue(1.345) - self.spin_rlm_max_iter = mk_spin() self.spin_rlm_max_iter.setRange(1, 500) self.spin_rlm_max_iter.setValue(50) - self.spin_rlm_tol = mk_dspin(decimals=8) self.spin_rlm_tol.setRange(1e-12, 1e-2) self.spin_rlm_tol.setValue(1e-6) self.output_params_stack = QtWidgets.QStackedWidget() - self.output_params_stack.setSizePolicy( - QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Fixed, - ) - page_none = QtWidgets.QWidget() self.output_params_stack.addWidget(page_none) - page_lasso = QtWidgets.QWidget() page_lasso_form = QtWidgets.QFormLayout(page_lasso) page_lasso_form.setContentsMargins(0, 0, 0, 0) page_lasso_form.addRow(self._label_with_help("Lasso alpha", "lasso_alpha"), self.spin_lasso) self.output_params_stack.addWidget(page_lasso) - page_rlm = QtWidgets.QWidget() - page_rlm_layout = QtWidgets.QVBoxLayout(page_rlm) - page_rlm_layout.setContentsMargins(0, 0, 0, 0) + page_rlm_v = QtWidgets.QVBoxLayout(page_rlm) + page_rlm_v.setContentsMargins(0, 0, 0, 0) robust_group = QtWidgets.QGroupBox("Robust fit") robust_form = QtWidgets.QFormLayout(robust_group) - robust_form.setRowWrapPolicy(QtWidgets.QFormLayout.RowWrapPolicy.WrapLongRows) robust_form.addRow(self._label_with_help("HuberT (t)", "rlm_huber_t"), self.spin_rlm_huber_t) robust_form.addRow(self._label_with_help("Max iter", "rlm_max_iter"), self.spin_rlm_max_iter) robust_form.addRow(self._label_with_help("Tol", "rlm_tol"), self.spin_rlm_tol) - page_rlm_layout.addWidget(robust_group) + page_rlm_v.addWidget(robust_group) self.output_params_stack.addWidget(page_rlm) - out_form.addRow(self._label_with_help("Output mode", "output_mode"), self.combo_output) + out_content = QtWidgets.QWidget() + out_form = QtWidgets.QFormLayout(out_content) + out_form.setFieldGrowthPolicy(QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + self.lbl_output_mode = self._label_with_help("Output mode", "output_mode") + self.lbl_reference_fit = self._label_with_help("Reference fit method", "reference_fit") + out_form.addRow(self.lbl_output_mode, self.combo_output) out_form.addRow("Definition", self.ed_output_definition) - out_form.addRow(self._label_with_help("Reference fit method", "reference_fit"), self.combo_ref_fit) - out_v.addLayout(out_form) - out_v.addWidget(self.output_params_stack) + out_form.addRow(self.lbl_reference_fit, self.combo_ref_fit) + out_form.addRow(self.output_params_stack) - # Config buttons - config_row = QtWidgets.QHBoxLayout() + # QC + Export card + self.btn_artifacts_panel = QtWidgets.QPushButton("Artifacts") + self.btn_qc = QtWidgets.QPushButton("Quality check") + self.btn_qc_batch = QtWidgets.QPushButton("Batch quality metrics") + self.btn_export = QtWidgets.QPushButton("Export CSV/H5") + self.btn_metadata = QtWidgets.QPushButton("Metadata") + self.btn_advanced = QtWidgets.QPushButton("Cutting / Sectioning") self.btn_save_config = QtWidgets.QPushButton("Save config") self.btn_load_config = QtWidgets.QPushButton("Load config") - self.btn_save_config.setProperty("class", "compactSmall") - self.btn_load_config.setProperty("class", "compactSmall") + for b in ( + self.btn_artifacts_panel, + self.btn_qc, + self.btn_qc_batch, + self.btn_export, + self.btn_metadata, + self.btn_advanced, + self.btn_save_config, + self.btn_load_config, + ): + b.setProperty("class", "compactSmall") + b.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_export.setProperty("class", "compactPrimarySmall") self.btn_save_config.clicked.connect(self._save_config) self.btn_load_config.clicked.connect(self._load_config) - config_row.addWidget(self.btn_save_config) - config_row.addWidget(self.btn_load_config) - config_row.addStretch(1) + self.btn_artifacts_panel.clicked.connect(self.artifactsRequested.emit) + self.btn_qc.clicked.connect(self.qcRequested.emit) + self.btn_qc_batch.clicked.connect(self.batchQcRequested.emit) + self.btn_export.clicked.connect(self.exportRequested.emit) + self.btn_metadata.clicked.connect(self.metadataRequested.emit) + self.btn_advanced.clicked.connect(self.advancedOptionsRequested.emit) + + self.chk_export_raw = QtWidgets.QCheckBox("Raw 465") + self.chk_export_raw.setChecked(True) + self.chk_export_iso = QtWidgets.QCheckBox("Isobestic 405") + self.chk_export_iso.setChecked(True) + self.chk_export_output = QtWidgets.QCheckBox("Processed output") + self.chk_export_output.setChecked(True) + self.chk_export_dio = QtWidgets.QCheckBox("DIO / trigger") + self.chk_export_dio.setChecked(True) + self.chk_export_baseline_sig = QtWidgets.QCheckBox("Baseline 465") + self.chk_export_baseline_sig.setChecked(True) + self.chk_export_baseline_ref = QtWidgets.QCheckBox("Baseline 405") + self.chk_export_baseline_ref.setChecked(True) + self.list_export_channels = CheckableListWidget() + self.list_export_channels.setMaximumHeight(84) + self.list_export_channels.setMinimumHeight(54) + self.list_export_dio = CheckableListWidget() + self.list_export_dio.setMaximumHeight(84) + self.list_export_dio.setMinimumHeight(54) + self._pending_export_channel_names: List[str] = [] + self._pending_export_trigger_names: List[str] = [] + self.list_export_dio.setEnabled(self.chk_export_dio.isChecked()) + + self.export_options_group = QtWidgets.QGroupBox("Export fields") + export_form = QtWidgets.QFormLayout(self.export_options_group) + export_form.setContentsMargins(6, 6, 6, 6) + export_form.addRow(self._label_with_help("Export fields", "export_selection")) + export_checks = QtWidgets.QGridLayout() + export_checks.setContentsMargins(0, 0, 0, 0) + export_checks.setHorizontalSpacing(10) + export_checks.setVerticalSpacing(4) + export_checks.addWidget(self.chk_export_raw, 0, 0) + export_checks.addWidget(self.chk_export_iso, 0, 1) + export_checks.addWidget(self.chk_export_output, 1, 0) + export_checks.addWidget(self.chk_export_dio, 1, 1) + export_checks.addWidget(self.chk_export_baseline_sig, 2, 0) + export_checks.addWidget(self.chk_export_baseline_ref, 2, 1) + export_form.addRow(export_checks) + export_form.addRow(self._label_with_help("AN channels", "export_analog_channels"), self.list_export_channels) + export_form.addRow(self._label_with_help("DIO channels", "export_dio_channels"), self.list_export_dio) + + qc_content = QtWidgets.QWidget() + qc_grid = QtWidgets.QGridLayout(qc_content) + qc_grid.setContentsMargins(0, 0, 0, 0) + qc_grid.setHorizontalSpacing(6) + qc_grid.setVerticalSpacing(6) + qc_grid.addWidget(self.btn_export, 0, 0, 1, 2) + qc_grid.addWidget(self.btn_artifacts_panel, 1, 0) + qc_grid.addWidget(self.btn_advanced, 1, 1) + qc_grid.addWidget(self.btn_qc, 2, 0) + qc_grid.addWidget(self.btn_qc_batch, 2, 1) + qc_grid.addWidget(self.btn_metadata, 3, 0) + qc_grid.addWidget(self.btn_save_config, 3, 1) + qc_grid.addWidget(self.btn_load_config, 4, 1) + qc_grid.setColumnStretch(0, 1) + qc_grid.setColumnStretch(1, 1) self.lbl_fs = QtWidgets.QLabel("FS: -") self.lbl_fs.setProperty("class", "hint") - - form.addRow(self._label_with_help("Artifact detection", "artifact_mode"), artifact_widget) - form.addRow(self._label_with_help("MAD threshold (k)", "mad_k"), self.spin_mad) - form.addRow(self._label_with_help("Adaptive window (s)", "adaptive_window_s"), self.spin_adapt_win) - form.addRow(self._label_with_help("Artifact pad (s)", "artifact_pad_s"), self.spin_pad) - form.addRow("Filtering", filter_widget) - form.addRow(self._label_with_help("Low-pass cutoff (Hz)", "lowpass_hz"), self.spin_lowpass) - form.addRow(self._label_with_help("Filter order", "filter_order"), self.spin_filt_order) - form.addRow(self._label_with_help("Target FS (Hz)", "target_fs_hz"), self.spin_target_fs) - form.addRow(self._label_with_help("Invert signal polarity", "invert_polarity"), self.cb_invert) - - form.addRow("Baseline", baseline_main_widget) - form.addRow(self.btn_toggle_advanced) - form.addRow(self.baseline_advanced_group) - - form.addRow(self.output_group) - form.addRow("Configuration", config_row) - form.addRow("", self.lbl_fs) + qc_grid.addWidget(self.lbl_fs, 5, 0, 1, 2) + + # Cards + self.card_artifacts = CollapsibleSection("Artifacts") + self.card_artifacts.set_content_widget(art_content) + self.card_filtering = CollapsibleSection("Filtering") + self.card_filtering.set_content_widget(filt_content) + self.card_baseline = CollapsibleSection("Baseline") + self.card_baseline.set_content_widget(base_content) + self.card_output = CollapsibleSection("Output") + self.card_output.set_content_widget(out_content) + self.card_actions = CollapsibleSection("QC + Export") + self.card_actions.set_content_widget(qc_content) + self.card_actions.set_expanded(True) + + # Expand defaults + self.card_artifacts.set_expanded(bool(self.cb_artifact.isChecked())) + self.card_filtering.set_expanded(bool(self.cb_filtering.isChecked())) + self.card_baseline.set_expanded(True) + self.card_output.set_expanded(True) + + root.addWidget(self.card_artifacts) + root.addWidget(self.card_filtering) + root.addWidget(self.card_baseline) + root.addWidget(self.card_output) + root.addWidget(self.card_actions) + root.addStretch(1) self._update_lambda_preview() self._update_output_definition() self._update_output_controls() + self._update_smoothing_controls(emit_signal=False) + self._update_section_summaries() def _update_artifact_enabled(self) -> None: enabled = self.cb_artifact.isChecked() @@ -1543,14 +1813,34 @@ def _update_artifact_enabled(self) -> None: self.spin_mad.setEnabled(enabled) self.spin_adapt_win.setEnabled(enabled) self.spin_pad.setEnabled(enabled) + if not enabled and self.card_artifacts.is_expanded(): + self.card_artifacts.set_expanded(False) + self._update_section_summaries() self.paramsChanged.emit() def _update_filtering_enabled(self) -> None: enabled = self.cb_filtering.isChecked() self.spin_lowpass.setEnabled(enabled) self.spin_filt_order.setEnabled(enabled) + self.spin_target_fs.setEnabled(enabled) + self.cb_invert.setEnabled(enabled) + self.cb_smoothing.setEnabled(enabled) + self._update_smoothing_controls(emit_signal=False) + if not enabled and self.card_filtering.is_expanded(): + self.card_filtering.set_expanded(False) + self._update_section_summaries() self.paramsChanged.emit() + def _update_smoothing_controls(self, emit_signal: bool = True) -> None: + enabled = self.cb_filtering.isChecked() and self.cb_smoothing.isChecked() + self.combo_smoothing.setEnabled(enabled) + self.spin_smoothing_window.setEnabled(enabled) + is_savgol = str(self.combo_smoothing.currentText()).startswith("Savitzky") + self.spin_smoothing_poly.setEnabled(enabled and is_savgol) + self._update_section_summaries() + if emit_signal: + self.paramsChanged.emit() + def _update_lambda_preview(self) -> None: lam = self._lambda_value() self.lbl_lam_preview.setText(f"= {lam:.2e}") @@ -1567,11 +1857,14 @@ def _update_output_definition(self) -> None: def _update_output_controls(self) -> None: fitted_mode = self._is_fitted_output_mode() + self.lbl_reference_fit.setVisible(fitted_mode) + self.combo_ref_fit.setVisible(fitted_mode) self.combo_ref_fit.setEnabled(fitted_mode) if not fitted_mode: self.output_params_stack.setCurrentIndex(0) self.output_params_stack.setVisible(False) + self._update_section_summaries() return method = self.combo_ref_fit.currentText() @@ -1584,6 +1877,57 @@ def _update_output_controls(self) -> None: self.output_params_stack.setCurrentIndex(idx) self.output_params_stack.setVisible(idx != 0) + self._update_section_summaries() + + def _fmt_num(self, value: float, decimals: int = 3) -> str: + text = f"{float(value):.{int(decimals)}f}" + text = text.rstrip("0").rstrip(".") + return text if text else "0" + + def _update_section_summaries(self) -> None: + if self.cb_artifact.isChecked(): + mode = self.combo_artifact.currentText() + method = "Adaptive MAD" if mode.startswith("Adaptive") else "Global MAD" + if mode.startswith("Adaptive"): + summary = ( + f"{method}, k={self._fmt_num(self.spin_mad.value(), 2)}, " + f"window={self._fmt_num(self.spin_adapt_win.value(), 2)}s, " + f"pad={self._fmt_num(self.spin_pad.value(), 2)}s" + ) + else: + summary = ( + f"{method}, k={self._fmt_num(self.spin_mad.value(), 2)}, " + f"pad={self._fmt_num(self.spin_pad.value(), 2)}s" + ) + else: + summary = "Off" + self.card_artifacts.set_summary(summary) + + if self.cb_filtering.isChecked(): + summary = ( + f"LP {self._fmt_num(self.spin_lowpass.value(), 2)} Hz, " + f"order {int(self.spin_filt_order.value())}, " + f"target {self._fmt_num(self.spin_target_fs.value(), 2)} Hz" + ) + if self.cb_smoothing.isChecked(): + method = self.combo_smoothing.currentText() + if method.startswith("Savitzky"): + smooth_desc = ( + f"SG win={self._fmt_num(self.spin_smoothing_window.value(), 3)}s, " + f"poly={int(self.spin_smoothing_poly.value())}" + ) + elif method.startswith("Moving average"): + smooth_desc = f"Mean win={self._fmt_num(self.spin_smoothing_window.value(), 3)}s" + else: + smooth_desc = f"Median win={self._fmt_num(self.spin_smoothing_window.value(), 3)}s" + summary = f"{summary} | {smooth_desc}" + else: + summary = "Off" + self.card_filtering.set_summary(summary) + + summary = f"{self.combo_baseline.currentText()}, lambda={self._lambda_value():.2e}" + self.card_baseline.set_summary(summary) + self.card_output.set_summary(self.combo_output.currentText()) def _wire(self) -> None: def emit_noargs(*_args) -> None: @@ -1597,6 +1941,8 @@ def emit_noargs(*_args) -> None: self.spin_lowpass, self.spin_filt_order, self.spin_target_fs, + self.spin_smoothing_window, + self.spin_smoothing_poly, self.combo_baseline, self.spin_lam_x, self.spin_lam_y, @@ -1623,9 +1969,33 @@ def emit_noargs(*_args) -> None: self.combo_output.currentIndexChanged.connect(lambda *_: self._update_output_controls()) self.combo_ref_fit.currentIndexChanged.connect(lambda *_: self._update_output_controls()) + # Keep collapsed card summaries synchronized with current values. + for w in widgets: + if isinstance(w, QtWidgets.QComboBox): + w.currentIndexChanged.connect(lambda *_: self._update_section_summaries()) + else: + w.valueChanged.connect(lambda *_: self._update_section_summaries()) + self.cb_artifact.stateChanged.connect(self._update_artifact_enabled) self.cb_filtering.stateChanged.connect(self._update_filtering_enabled) + self.cb_smoothing.stateChanged.connect(self._update_smoothing_controls) + self.combo_smoothing.currentIndexChanged.connect(lambda *_: self._update_smoothing_controls()) self.cb_invert.stateChanged.connect(emit_noargs) + self.cb_show_artifact_overlay.toggled.connect(lambda v: self.artifactOverlayToggled.emit(bool(v))) + self.chk_export_dio.toggled.connect(self.list_export_dio.setEnabled) + self.list_export_channels.changed.connect(self._on_export_channel_selection_changed) + self.list_export_channels.changed.connect(emit_noargs) + self.list_export_dio.changed.connect(self._on_export_trigger_selection_changed) + self.list_export_dio.changed.connect(emit_noargs) + for cb in ( + self.chk_export_raw, + self.chk_export_iso, + self.chk_export_output, + self.chk_export_dio, + self.chk_export_baseline_sig, + self.chk_export_baseline_ref, + ): + cb.toggled.connect(emit_noargs) def _toggle_advanced_baseline(self) -> None: """Toggle visibility of baseline advanced parameters.""" @@ -1635,6 +2005,95 @@ def _toggle_advanced_baseline(self) -> None: "Hide advanced baseline options" if not is_visible else "Show advanced baseline options" ) + def set_config_state_hooks( + self, + exporter: Optional[Callable[[], Dict[str, object]]], + importer: Optional[Callable[[Dict[str, object]], None]], + ) -> None: + self._config_state_exporter = exporter + self._config_state_importer = importer + + def export_selection(self) -> ExportSelection: + return ExportSelection( + raw=self.chk_export_raw.isChecked(), + isobestic=self.chk_export_iso.isChecked(), + output=self.chk_export_output.isChecked(), + dio=self.chk_export_dio.isChecked(), + baseline_sig=self.chk_export_baseline_sig.isChecked(), + baseline_ref=self.chk_export_baseline_ref.isChecked(), + ) + + def export_selection_summary(self) -> str: + selection = self.export_selection() + parts: List[str] = [] + chans = self.export_channel_names() + if chans: + parts.append(f"ANx{len(chans)}") + if selection.output: + parts.append("output") + if selection.dio: + dio_names = self.export_trigger_names() + parts.append(f"DIOx{len(dio_names)}" if dio_names else "DIO") + if selection.raw: + parts.append("raw") + if selection.isobestic: + parts.append("isobestic") + if selection.baseline_sig or selection.baseline_ref: + if selection.baseline_sig and selection.baseline_ref: + parts.append("baselines") + elif selection.baseline_sig: + parts.append("baseline465") + else: + parts.append("baseline405") + if not parts: + parts.append("time only") + return "CSV/H5: " + " + ".join(parts) + + def set_export_selection(self, selection: ExportSelection) -> None: + selection = selection if isinstance(selection, ExportSelection) else ExportSelection() + self.chk_export_raw.setChecked(bool(selection.raw)) + self.chk_export_iso.setChecked(bool(selection.isobestic)) + self.chk_export_output.setChecked(bool(selection.output)) + self.chk_export_dio.setChecked(bool(selection.dio)) + self.chk_export_baseline_sig.setChecked(bool(selection.baseline_sig)) + self.chk_export_baseline_ref.setChecked(bool(selection.baseline_ref)) + + def export_channel_names(self) -> List[str]: + checked = self.list_export_channels.checked_values() + return checked or list(self._pending_export_channel_names) + + def export_trigger_names(self) -> List[str]: + checked = self.list_export_dio.checked_values() + return checked or list(self._pending_export_trigger_names) + + def _on_export_channel_selection_changed(self) -> None: + self._pending_export_channel_names = self.list_export_channels.checked_values() + + def _on_export_trigger_selection_changed(self) -> None: + self._pending_export_trigger_names = self.list_export_dio.checked_values() + + def set_available_export_channels(self, channels: List[str], preferred: Optional[List[str]] = None) -> None: + current = list(preferred if preferred is not None else self.export_channel_names()) + items = [(name, name) for name in channels or [] if str(name or "").strip()] + self.list_export_channels.set_items(items, checked_values=current) + self._pending_export_channel_names = [name for name in current if name in {v for _, v in items}] + + def set_available_export_triggers(self, triggers: List[str], preferred: Optional[List[str]] = None) -> None: + current = list(preferred if preferred is not None else self.export_trigger_names()) + items = [(name, name) for name in triggers or [] if str(name or "").strip()] + self.list_export_dio.set_items(items, checked_values=current) + self._pending_export_trigger_names = [name for name in current if name in {v for _, v in items}] + + def set_export_channel_names(self, channel_names: List[str]) -> None: + names = [str(name or "").strip() for name in channel_names or [] if str(name or "").strip()] + self._pending_export_channel_names = names + self.list_export_channels.set_checked_values(names) + + def set_export_trigger_names(self, trigger_names: List[str]) -> None: + names = [str(name or "").strip() for name in trigger_names or [] if str(name or "").strip()] + self._pending_export_trigger_names = names + self.list_export_dio.set_checked_values(names) + def _save_config(self) -> None: """Save current preprocessing parameters to a JSON file.""" params = self.get_params() @@ -1650,9 +2109,17 @@ def _save_config(self) -> None: config = { "artifact_detection_enabled": self.cb_artifact.isChecked(), + "artifact_overlay_visible": self.cb_show_artifact_overlay.isChecked(), "filtering_enabled": self.cb_filtering.isChecked(), "parameters": params.to_dict(), } + if callable(self._config_state_exporter): + try: + extra_state = self._config_state_exporter() + if isinstance(extra_state, dict) and extra_state: + config["ui_state"] = extra_state + except Exception: + pass try: import json @@ -1687,8 +2154,17 @@ def _load_config(self) -> None: # Load toggles if "artifact_detection_enabled" in config: self.cb_artifact.setChecked(config["artifact_detection_enabled"]) + if "artifact_overlay_visible" in config: + self.cb_show_artifact_overlay.setChecked(bool(config["artifact_overlay_visible"])) if "filtering_enabled" in config: self.cb_filtering.setChecked(config["filtering_enabled"]) + if callable(self._config_state_importer): + try: + ui_state = config.get("ui_state") + if isinstance(ui_state, dict): + self._config_state_importer(ui_state) + except Exception: + pass QtWidgets.QMessageBox.information(self, "Success", "Configuration loaded successfully.") except Exception as e: @@ -1709,6 +2185,10 @@ def get_params(self) -> ProcessingParams: lowpass_hz=float(self.spin_lowpass.value()), filter_order=int(self.spin_filt_order.value()), target_fs_hz=float(self.spin_target_fs.value()), + smoothing_enabled=self.cb_filtering.isChecked() and self.cb_smoothing.isChecked(), + smoothing_method=self.combo_smoothing.currentText(), + smoothing_window_s=float(self.spin_smoothing_window.value()), + smoothing_polyorder=int(self.spin_smoothing_poly.value()), baseline_method=self.combo_baseline.currentText(), baseline_lambda=float(self._lambda_value()), baseline_diff_order=int(self.spin_diff.value()), @@ -1735,6 +2215,10 @@ def set_params(self, params: ProcessingParams) -> None: self.spin_lowpass.setValue(float(params.lowpass_hz)) self.spin_filt_order.setValue(int(params.filter_order)) self.spin_target_fs.setValue(float(params.target_fs_hz)) + self.cb_smoothing.setChecked(bool(getattr(params, "smoothing_enabled", False))) + self.combo_smoothing.setCurrentText(str(getattr(params, "smoothing_method", "Savitzky-Golay"))) + self.spin_smoothing_window.setValue(float(getattr(params, "smoothing_window_s", 0.200))) + self.spin_smoothing_poly.setValue(int(getattr(params, "smoothing_polyorder", 2))) self.cb_invert.setChecked(bool(getattr(params, "invert_polarity", False))) self.combo_baseline.setCurrentText(str(params.baseline_method)) @@ -1760,16 +2244,24 @@ def set_params(self, params: ProcessingParams) -> None: self._update_lambda_preview() self._update_output_definition() self._update_output_controls() + self._update_smoothing_controls(emit_signal=False) + self._update_section_summaries() def set_fs_info(self, fs_actual: float, fs_target: float, fs_used: float) -> None: - self.lbl_fs.setText(f"FS: actual={fs_actual:.2f} Hz → used={fs_used:.2f} Hz (target={fs_target:.2f})") + self.lbl_fs.setText(f"FS: actual={fs_actual:.2f} Hz -> used={fs_used:.2f} Hz (target={fs_target:.2f})") + def artifact_overlay_visible(self) -> bool: + return bool(self.cb_show_artifact_overlay.isChecked()) + + def set_artifact_overlay_visible(self, visible: bool) -> None: + self.cb_show_artifact_overlay.setChecked(bool(visible)) # ----------------------------- Plot dashboard ----------------------------- class ArtifactSelectViewBox(pg.ViewBox): dragSelectionFinished = QtCore.Signal(float, float) dragSelectionCleared = QtCore.Signal() + dragSelectionContextRequested = QtCore.Signal() def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -1818,28 +2310,63 @@ def mouseDragEvent(self, ev, axis=None) -> None: def mouseClickEvent(self, ev) -> None: if self._drag_enabled and ev.button() == QtCore.Qt.MouseButton.RightButton: - self.clear_selection() - self.dragSelectionCleared.emit() + if self._rect_item.isVisible(): + self.dragSelectionContextRequested.emit() + else: + self.clear_selection() + self.dragSelectionCleared.emit() ev.accept() return super().mouseClickEvent(ev) + def contextMenuEvent(self, ev) -> None: + if self._drag_enabled and self._rect_item.isVisible(): + self.dragSelectionContextRequested.emit() + ev.accept() + else: + super().contextMenuEvent(ev) + + +class PreprocessingSelector(pg.LinearRegionItem): + sigContextRequested = QtCore.Signal() + + def mouseClickEvent(self, ev) -> None: + if ev.button() == QtCore.Qt.MouseButton.RightButton: + self.sigContextRequested.emit() + ev.accept() + else: + super().mouseClickEvent(ev) + + def contextMenuEvent(self, ev) -> None: + self.sigContextRequested.emit() + ev.accept() + + class PlotDashboard(QtWidgets.QWidget): manualRegionFromSelectorRequested = QtCore.Signal() manualRegionFromDragRequested = QtCore.Signal(float, float) clearManualRegionsRequested = QtCore.Signal() showArtifactsRequested = QtCore.Signal() boxSelectionCleared = QtCore.Signal() + boxSelectionContextRequested = QtCore.Signal() + artifactThresholdsToggled = QtCore.Signal(bool) xRangeChanged = QtCore.Signal(float, float) def __init__(self, parent=None) -> None: super().__init__(parent) self._sync_guard = False + self._artifact_overlay_visible = True + self._artifact_thresholds_visible = True + self._plot_background_mode = "dark" + self._plot_grid_visible = True self._artifact_regions: List[pg.LinearRegionItem] = [] self._artifact_region_bounds: List[Tuple[float, float]] = [] self._artifact_labels: List[pg.TextItem] = [] + self._last_overlay_time: Optional[np.ndarray] = None + self._last_overlay_signal: Optional[np.ndarray] = None + self._last_overlay_regions: List[Tuple[float, float]] = [] self._artifact_pen_default = pg.mkPen((240, 130, 90), width=1.0) self._artifact_brush_default = pg.mkBrush(240, 130, 90, 40) self._artifact_pen_selected = pg.mkPen((255, 220, 120), width=2.0) @@ -1853,23 +2380,41 @@ def _build_ui(self) -> None: top = QtWidgets.QHBoxLayout() self.lbl_title = QtWidgets.QLabel("No file loaded") self.lbl_title.setStyleSheet("font-weight: 900; font-size: 12pt;") + self.lbl_status = QtWidgets.QLabel("Channel: - | A/D: None | Fs: - -> - Hz | Mode: -") + self.lbl_status.setProperty("class", "hint") + self.lbl_status.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) top.addWidget(self.lbl_title) top.addStretch(1) + top.addWidget(self.lbl_status) + v.addLayout(top) + tools = QtWidgets.QHBoxLayout() self.btn_add_region = QtWidgets.QPushButton("Add from selector") self.btn_clear_regions = QtWidgets.QPushButton("Clear manual") - self.btn_artifacts = QtWidgets.QPushButton("Artifacts…") + self.btn_artifacts = QtWidgets.QPushButton("Artifacts") self.btn_box_select = QtWidgets.QPushButton("Box select") self.btn_box_select.setCheckable(True) - self.btn_box_select.setProperty("class", "compactSmall") - top.addWidget(self.btn_add_region) - top.addWidget(self.btn_clear_regions) - top.addWidget(self.btn_artifacts) - top.addWidget(self.btn_box_select) - v.addLayout(top) + self.btn_thresholds = QtWidgets.QPushButton("Thresholds: ON") + self.btn_thresholds.setCheckable(True) + self.btn_thresholds.setChecked(True) + for b in ( + self.btn_add_region, + self.btn_clear_regions, + self.btn_artifacts, + self.btn_box_select, + self.btn_thresholds, + ): + b.setProperty("class", "compactSmall") + tools.addWidget(self.btn_add_region) + tools.addWidget(self.btn_clear_regions) + tools.addWidget(self.btn_artifacts) + tools.addWidget(self.btn_box_select) + tools.addWidget(self.btn_thresholds) + tools.addStretch(1) + v.addLayout(tools) self._raw_vb = ArtifactSelectViewBox() - self.plot_raw = pg.PlotWidget(viewBox=self._raw_vb, title="Raw signals (465 / 405)") + self.plot_raw = pg.PlotWidget(viewBox=self._raw_vb, title="raw signal") self.plot_proc = pg.PlotWidget(title="Filtered + baselines") self.plot_out = pg.PlotWidget(title="Output") for w in (self.plot_raw, self.plot_proc, self.plot_out): @@ -1909,14 +2454,17 @@ def _build_ui(self) -> None: ) self.curve_out = self.plot_out.plot(pen=pg.mkPen((90, 190, 255), width=1.2)) + self._raw_y_curves = [self.curve_465, self.curve_405, self.curve_b465_raw, self.curve_b405_raw] + self._proc_y_curves = [self.curve_f465, self.curve_f405, self.curve_b465, self.curve_b405] + self._out_y_curves = [self.curve_out] - self.selector = pg.LinearRegionItem(values=(0, 1), brush=(80, 120, 200, 60)) + self.selector = PreprocessingSelector(values=(0, 1), brush=(80, 120, 200, 60)) self.plot_raw.addItem(self.selector) self._dio_pen = pg.mkPen((230, 180, 80), width=1.2) - self.vb_dio_raw, self.curve_dio_raw = self._add_dio_axis(self.plot_raw, "DIO") - self.vb_dio_proc, self.curve_dio_proc = self._add_dio_axis(self.plot_proc, "DIO") - self.vb_dio_out, self.curve_dio_out = self._add_dio_axis(self.plot_out, "DIO") + self.vb_dio_raw, self.curve_dio_raw = self._add_dio_axis(self.plot_raw, "A/D") + self.vb_dio_proc, self.curve_dio_proc = self._add_dio_axis(self.plot_proc, "A/D") + self.vb_dio_out, self.curve_dio_out = self._add_dio_axis(self.plot_out, "A/D") self.lbl_log = QtWidgets.QLabel("") self.lbl_log.setProperty("class", "hint") @@ -1934,6 +2482,7 @@ def _build_ui(self) -> None: self.btn_clear_regions.clicked.connect(self.clearManualRegionsRequested.emit) self.btn_artifacts.clicked.connect(self.showArtifactsRequested.emit) self.btn_box_select.toggled.connect(self._toggle_box_select) + self.btn_thresholds.toggled.connect(self._on_thresholds_toggled) self.plot_raw.getViewBox().sigXRangeChanged.connect(self._emit_xrange_from_any) self.plot_proc.getViewBox().sigXRangeChanged.connect(self._emit_xrange_from_any) @@ -1941,8 +2490,56 @@ def _build_ui(self) -> None: self._raw_vb.dragSelectionFinished.connect(self._on_drag_select_finished) self._raw_vb.dragSelectionCleared.connect(self._on_drag_select_cleared) + self._raw_vb.dragSelectionContextRequested.connect(self.boxSelectionContextRequested.emit) + self.selector.sigContextRequested.connect(self.boxSelectionContextRequested.emit) + self._sync_artifact_threshold_curves_visibility() self._toggle_box_select(False) + self.set_plot_appearance(self._plot_background_mode, self._plot_grid_visible) + + def _normalize_plot_background_mode(self, value: object) -> str: + mode = str(value or "").strip().lower() + if mode in {"white", "light", "w"}: + return "white" + return "dark" + + def set_plot_appearance(self, background_mode: str, show_grid: bool) -> None: + self._plot_background_mode = self._normalize_plot_background_mode(background_mode) + self._plot_grid_visible = bool(show_grid) + + if self._plot_background_mode == "white": + bg = pg.mkColor("w") + axis_pen = pg.mkPen((35, 35, 35), width=1.0) + text_pen = pg.mkPen((35, 35, 35), width=1.0) + grid_alpha = 0.20 + else: + bg = pg.mkColor((18, 22, 30)) + axis_pen = pg.mkPen((200, 205, 215), width=1.0) + text_pen = pg.mkPen((200, 205, 215), width=1.0) + grid_alpha = 0.25 + + for plot in (self.plot_raw, self.plot_proc, self.plot_out): + try: + plot.setBackground(bg) + plot.showGrid( + x=self._plot_grid_visible, + y=self._plot_grid_visible, + alpha=grid_alpha if self._plot_grid_visible else 0.0, + ) + pi = plot.getPlotItem() + for axis_name in ("left", "right", "bottom"): + axis = pi.getAxis(axis_name) + if axis is not None: + axis.setPen(axis_pen) + axis.setTextPen(text_pen) + except Exception: + pass + + def plot_background_mode(self) -> str: + return str(self._plot_background_mode) + + def plot_grid_visible(self) -> bool: + return bool(self._plot_grid_visible) def _add_dio_axis(self, plot: pg.PlotWidget, label: str): pi = plot.getPlotItem() @@ -1990,6 +2587,19 @@ def _toggle_box_select(self, enabled: bool) -> None: self._raw_vb.set_drag_enabled(enabled) self.btn_box_select.setText("Box select: ON" if enabled else "Box select") + def _sync_artifact_threshold_curves_visibility(self) -> None: + visible = bool(self._artifact_thresholds_visible) + self.curve_thr_hi.setVisible(visible) + self.curve_thr_lo.setVisible(visible) + self.btn_thresholds.blockSignals(True) + self.btn_thresholds.setChecked(visible) + self.btn_thresholds.setText("Thresholds: ON" if visible else "Thresholds: OFF") + self.btn_thresholds.blockSignals(False) + + def _on_thresholds_toggled(self, checked: bool) -> None: + self.set_artifact_thresholds_visible(bool(checked)) + self.artifactThresholdsToggled.emit(bool(checked)) + def set_xrange_all(self, x0: float, x1: float) -> None: self._sync_guard = True try: @@ -2007,10 +2617,67 @@ def set_full_xrange(self, t: np.ndarray) -> None: if not np.isfinite(x0) or not np.isfinite(x1) or x0 == x1: return self.set_xrange_all(x0, x1) + self.auto_range_all(x0=x0, x1=x1) + + def _auto_range_plot( + self, + plot: pg.PlotWidget, + curves: List[pg.PlotCurveItem], + x0: Optional[float], + x1: Optional[float], + ) -> None: + ymins: List[float] = [] + ymaxs: List[float] = [] + for curve in curves: + try: + xx, yy = curve.getData() + except Exception: + continue + if xx is None or yy is None: + continue + x = np.asarray(xx, float) + y = np.asarray(yy, float) + if x.size == 0 or y.size == 0: + continue + m = np.isfinite(x) & np.isfinite(y) + if x0 is not None and x1 is not None: + m &= (x >= float(x0)) & (x <= float(x1)) + if not np.any(m): + continue + yv = y[m] + ymins.append(float(np.nanmin(yv))) + ymaxs.append(float(np.nanmax(yv))) + + if ymins and ymaxs: + ymin = min(ymins) + ymax = max(ymaxs) + if not np.isfinite(ymin) or not np.isfinite(ymax): + return + if ymin == ymax: + span = abs(ymin) if ymin != 0 else 1.0 + ymin -= 0.5 * span + ymax += 0.5 * span + plot.setYRange(float(ymin), float(ymax), padding=0.08) + else: + try: + plot.getPlotItem().enableAutoRange(axis=pg.ViewBox.YAxis, enable=True) + plot.getPlotItem().autoRange() + except Exception: + pass + + def auto_range_all(self, x0: Optional[float] = None, x1: Optional[float] = None) -> None: + if x0 is not None and x1 is not None and np.isfinite(x0) and np.isfinite(x1) and x1 > x0: + self.set_xrange_all(float(x0), float(x1)) + self._auto_range_plot(self.plot_raw, self._raw_y_curves, x0, x1) + self._auto_range_plot(self.plot_proc, self._proc_y_curves, x0, x1) + self._auto_range_plot(self.plot_out, self._out_y_curves, x0, x1) def set_title(self, text: str) -> None: self.lbl_title.setText(text) + def set_status(self, text: str) -> None: + self.lbl_status.setText(text or "") + def set_log(self, msg: str) -> None: self.lbl_log.setText(msg) @@ -2018,6 +2685,13 @@ def selector_region(self) -> Tuple[float, float]: r = self.selector.getRegion() return float(min(r)), float(max(r)) + def selector_visible(self) -> bool: + return bool(self.selector.isVisible()) + + def set_selector_region(self, t0: float, t1: float, visible: bool = True) -> None: + self.selector.setRegion((float(min(t0, t1)), float(max(t0, t1)))) + self.selector.setVisible(bool(visible)) + def _scale_reference_to_signal(self, sig: np.ndarray, ref: np.ndarray) -> np.ndarray: s = np.asarray(sig, float) r = np.asarray(ref, float) @@ -2047,35 +2721,91 @@ def _update_artifact_overlays( raw_sig: np.ndarray, regions: Optional[List[Tuple[float, float]]], ) -> None: + self._last_overlay_time = None if t is None else np.asarray(t, float) + self._last_overlay_signal = None if raw_sig is None else np.asarray(raw_sig, float) + self._last_overlay_regions = list(regions or []) self._clear_artifact_overlays() + if not self._artifact_overlay_visible: + return if t is None or raw_sig is None or not regions: return tt = np.asarray(t, float) yy = np.asarray(raw_sig, float) - for idx, (a, b) in enumerate(regions, start=1): - region = pg.LinearRegionItem( - values=(float(a), float(b)), - movable=False, - brush=self._artifact_brush_default, - pen=self._artifact_pen_default, + + # Optimization: use a single PathItem for many regions to avoid scene graph bloat. + if len(regions) > 25: + y0, y1 = -1e12, 1e12 # Large range for vertical bars + path = QtGui.QPainterPath() + for a, b in regions: + path.addRect(QtCore.QRectF(float(a), y0, float(b - a), y1 - y0)) + + path_item = pg.PathItem(path, pen=self._artifact_pen_default, brush=self._artifact_brush_default) + path_item.setZValue(8) + self.plot_raw.addItem(path_item) + self._artifact_regions.append(path_item) + self._artifact_region_bounds.extend(regions) + + # Limit labels to first 100 to avoid lag + for idx, (a, b) in enumerate(regions[:100], start=1): + mask = (tt >= float(a)) & (tt <= float(b)) + if np.any(mask): + y_pos = float(np.nanmax(yy[mask])) + else: + (vy0, vy1) = self.plot_raw.getViewBox().viewRange()[1] + y_pos = float(vy1 - 0.05 * (vy1 - vy0)) + + label = pg.TextItem(str(idx), color=(240, 240, 240), anchor=(0.5, 0.5)) + label.setPos(float((a + b) * 0.5), y_pos) + label.setZValue(9) + self.plot_raw.addItem(label) + self._artifact_labels.append(label) + else: + for idx, (a, b) in enumerate(regions, start=1): + region = pg.LinearRegionItem( + values=(float(a), float(b)), + movable=False, + brush=self._artifact_brush_default, + pen=self._artifact_pen_default, + ) + region.setZValue(8) + self.plot_raw.addItem(region) + self._artifact_regions.append(region) + self._artifact_region_bounds.append((float(a), float(b))) + + mask = (tt >= float(a)) & (tt <= float(b)) + if np.any(mask) and np.any(np.isfinite(yy[mask])): + y_pos = float(np.nanmax(yy[mask])) + else: + (y0, y1) = self.plot_raw.getViewBox().viewRange()[1] + y_pos = float(y1 - 0.05 * (y1 - y0)) + + label = pg.TextItem(str(idx), color=(240, 240, 240), anchor=(0.5, 0.5)) + label.setPos(float((a + b) * 0.5), y_pos) + label.setZValue(9) + self.plot_raw.addItem(label) + self._artifact_labels.append(label) + + def set_artifact_overlay_visible(self, visible: bool) -> None: + self._artifact_overlay_visible = bool(visible) + if not self._artifact_overlay_visible: + self._clear_artifact_overlays() + return + if self._last_overlay_time is not None and self._last_overlay_signal is not None: + self._update_artifact_overlays( + self._last_overlay_time, + self._last_overlay_signal, + self._last_overlay_regions, ) - region.setZValue(8) - self.plot_raw.addItem(region) - self._artifact_regions.append(region) - self._artifact_region_bounds.append((float(a), float(b))) - - mask = (tt >= float(a)) & (tt <= float(b)) - if np.any(mask) and np.any(np.isfinite(yy[mask])): - y_pos = float(np.nanmax(yy[mask])) - else: - (y0, y1) = self.plot_raw.getViewBox().viewRange()[1] - y_pos = float(y1 - 0.05 * (y1 - y0)) - label = pg.TextItem(str(idx), color=(240, 240, 240), anchor=(0.5, 0.5)) - label.setPos(float((a + b) * 0.5), y_pos) - label.setZValue(9) - self.plot_raw.addItem(label) - self._artifact_labels.append(label) + def artifact_overlay_visible(self) -> bool: + return bool(self._artifact_overlay_visible) + + def set_artifact_thresholds_visible(self, visible: bool) -> None: + self._artifact_thresholds_visible = bool(visible) + self._sync_artifact_threshold_curves_visibility() + + def artifact_thresholds_visible(self) -> bool: + return bool(self._artifact_thresholds_visible) def highlight_artifact_regions(self, regions: List[Tuple[float, float]]) -> None: if not self._artifact_regions or not self._artifact_region_bounds: @@ -2087,12 +2817,15 @@ def _overlaps(a: Tuple[float, float], b: Tuple[float, float], tol: float = 1e-3) for item, bounds in zip(self._artifact_regions, self._artifact_region_bounds): is_sel = any(_overlaps(bounds, s) for s in selected) - if is_sel: - item.setBrush(self._artifact_brush_selected) - item.setPen(self._artifact_pen_selected) - else: - item.setBrush(self._artifact_brush_default) - item.setPen(self._artifact_pen_default) + brush = self._artifact_brush_selected if is_sel else self._artifact_brush_default + pen = self._artifact_pen_selected if is_sel else self._artifact_pen_default + + item.setBrush(brush) + if hasattr(item, "setPen"): + item.setPen(pen) + elif hasattr(item, "lines"): + for line in item.lines: + line.setPen(pen) def _set_dio(self, t: np.ndarray, dio: Optional[np.ndarray], name: str = "") -> None: if dio is None or np.asarray(dio).size == 0: @@ -2111,13 +2844,13 @@ def _set_dio(self, t: np.ndarray, dio: Optional[np.ndarray], name: str = "") -> self.curve_dio_out.setData(tt, yy, connect="finite", skipFiniteCheck=True) if name: - self.plot_raw.getPlotItem().getAxis("right").setLabel(f"DIO ({name})") - self.plot_proc.getPlotItem().getAxis("right").setLabel(f"DIO ({name})") - self.plot_out.getPlotItem().getAxis("right").setLabel(f"DIO ({name})") + self.plot_raw.getPlotItem().getAxis("right").setLabel(f"A/D ({name})") + self.plot_proc.getPlotItem().getAxis("right").setLabel(f"A/D ({name})") + self.plot_out.getPlotItem().getAxis("right").setLabel(f"A/D ({name})") else: - self.plot_raw.getPlotItem().getAxis("right").setLabel("DIO") - self.plot_proc.getPlotItem().getAxis("right").setLabel("DIO") - self.plot_out.getPlotItem().getAxis("right").setLabel("DIO") + self.plot_raw.getPlotItem().getAxis("right").setLabel("A/D") + self.plot_proc.getPlotItem().getAxis("right").setLabel("A/D") + self.plot_out.getPlotItem().getAxis("right").setLabel("A/D") # -------------------- Compatibility API expected by main.py -------------------- @@ -2131,6 +2864,14 @@ def show_raw(self, *args, **kwargs) -> None: - show_raw(time=..., signal_465=..., reference_405=...) - show_raw(time=..., raw_signal=..., raw_reference=...) """ + preserve_view = False + for key in ("preserve_view", "preserve_xrange", "keep_view"): + if key in kwargs: + try: + preserve_view = preserve_view or bool(kwargs.pop(key)) + except Exception: + kwargs.pop(key, None) + # Positional support: (time, sig, ref) t = s = r = None if len(args) >= 3: @@ -2147,6 +2888,7 @@ def show_raw(self, *args, **kwargs) -> None: self.curve_405.setData([], []) self.curve_thr_hi.setData([], []) self.curve_thr_lo.setData([], []) + self._sync_artifact_threshold_curves_visibility() self._set_dio(np.asarray([]), None, "") return @@ -2159,10 +2901,11 @@ def show_raw(self, *args, **kwargs) -> None: self.curve_465.setData(t, s, connect="finite", skipFiniteCheck=True) r_scaled = self._scale_reference_to_signal(s, r) self.curve_405.setData(t, r_scaled, connect="finite", skipFiniteCheck=True) - self.set_full_xrange(t) + if not preserve_view: + self.set_full_xrange(t) self._clear_artifact_overlays() - # Thresholds (either scalars or arrays) — MUST NOT use "or" on arrays + # Thresholds (either scalars or arrays): do not use "or" on arrays. thr_hi = _first_not_none(kwargs, "thr_hi", "raw_thr_hi", "mad_hi", "hi_thr") thr_lo = _first_not_none(kwargs, "thr_lo", "raw_thr_lo", "mad_lo", "lo_thr") @@ -2179,6 +2922,7 @@ def show_raw(self, *args, **kwargs) -> None: nn = min(t.size, th.size, tl.size) self.curve_thr_hi.setData(t[:nn], th[:nn], connect="finite", skipFiniteCheck=True) self.curve_thr_lo.setData(t[:nn], tl[:nn], connect="finite", skipFiniteCheck=True) + self._sync_artifact_threshold_curves_visibility() # Baselines for raw plot (if available) baseline_sig = _first_not_none(kwargs, "baseline_sig", "b_sig", "sig_base") @@ -2215,6 +2959,14 @@ def show_processing(self, *args, **kwargs) -> None: - show_processing(time, sig_f=..., ref_f=..., baseline_sig=..., baseline_ref=...) - show_processing(time=..., sig_f=..., ref_f=..., b_sig=..., b_ref=...) """ + preserve_view = False + for key in ("preserve_view", "preserve_xrange", "keep_view"): + if key in kwargs: + try: + preserve_view = preserve_view or bool(kwargs.pop(key)) + except Exception: + kwargs.pop(key, None) + if len(args) >= 1: t = args[0] else: @@ -2252,7 +3004,8 @@ def _set_curve(curve, y): dio = _first_not_none(kwargs, "dio", "digital", "dio_y") dio_name = _first_not_none(kwargs, "dio_name", "digital_name", "trigger_name", default="") or "" self._set_dio(t, dio, str(dio_name)) - self.set_full_xrange(t) + if not preserve_view: + self.set_full_xrange(t) def show_output(self, *args, **kwargs) -> None: """ @@ -2262,6 +3015,14 @@ def show_output(self, *args, **kwargs) -> None: - show_output(time, output, label="...", dio=..., dio_name=...) - show_output(time=..., y=..., label=...) """ + preserve_view = False + for key in ("preserve_view", "preserve_xrange", "keep_view"): + if key in kwargs: + try: + preserve_view = preserve_view or bool(kwargs.pop(key)) + except Exception: + kwargs.pop(key, None) + if len(args) >= 2: t, y = args[0], args[1] else: @@ -2279,7 +3040,8 @@ def show_output(self, *args, **kwargs) -> None: t, y = t[:n], y[:n] self.curve_out.setData(t, y, connect="finite", skipFiniteCheck=True) - self.set_full_xrange(t) + if not preserve_view: + self.set_full_xrange(t) label = _first_not_none(kwargs, "label", "output_label", default="Output") context = _first_not_none(kwargs, "output_context", "label_context", default="") @@ -2292,23 +3054,27 @@ def show_output(self, *args, **kwargs) -> None: # -------------------- Modern API (kept) -------------------- - def update_plots(self, processed: ProcessedTrial) -> None: + def update_plots(self, processed: ProcessedTrial, preserve_view: bool = False) -> None: t = np.asarray(processed.time, float) self.show_raw( t, processed.raw_signal, processed.raw_reference, dio=processed.dio, dio_name=processed.dio_name, - thr_hi=processed.raw_thr_hi, thr_lo=processed.raw_thr_lo + thr_hi=processed.raw_thr_hi, thr_lo=processed.raw_thr_lo, + preserve_view=preserve_view, ) self.show_processing( t, sig_f=processed.sig_f, ref_f=processed.ref_f, baseline_sig=processed.baseline_sig, baseline_ref=processed.baseline_ref, - dio=processed.dio, dio_name=processed.dio_name + dio=processed.dio, dio_name=processed.dio_name, + preserve_view=preserve_view, ) self.show_output( t, processed.output, label=processed.output_label, output_context=getattr(processed, "output_context", ""), - dio=processed.dio, dio_name=processed.dio_name + dio=processed.dio, dio_name=processed.dio_name, + preserve_view=preserve_view, ) self._update_artifact_overlays(t, processed.raw_signal, processed.artifact_regions_sec) + diff --git a/pyBer/gui_widgets.py b/pyBer/gui_widgets.py index 70af07d..c91981c 100644 --- a/pyBer/gui_widgets.py +++ b/pyBer/gui_widgets.py @@ -513,7 +513,7 @@ def __init__(self, parent=None) -> None: self.btn_artifacts = QtWidgets.QPushButton("Artifacts…") form.addRow("Channel (preview)", self.combo_channel) - form.addRow("Digital trigger (overlay)", self.combo_trigger) + form.addRow("Analog/Digital channel (overlay)", self.combo_trigger) form.addRow(self.chk_split) form.addRow(self.btn_metadata) form.addRow(self.btn_update) diff --git a/pyBer/main.py b/pyBer/main.py index 8b910b8..fc20c9d 100644 --- a/pyBer/main.py +++ b/pyBer/main.py @@ -1,6 +1,6 @@ # main.py """ -Fiber Photometry Processor (Doric .doric) — PySide6 + pyqtgraph +Fiber Photometry Processor (Doric .doric) - PySide6 + pyqtgraph Run: python main.py @@ -13,13 +13,17 @@ import os import json +import logging +import sys from typing import Callable, Dict, List, Optional, Tuple -from PySide6 import QtCore, QtWidgets +from PySide6 import QtCore, QtGui, QtWidgets import pyqtgraph as pg +from pyqtgraph.dockarea import DockArea, Dock import h5py from analysis_core import ( + ExportSelection, PhotometryProcessor, ProcessingParams, LoadedDoricFile, @@ -34,11 +38,105 @@ safe_divide, _lowpass_sos, ) -from gui_preprocessing import FileQueuePanel, ParameterPanel, PlotDashboard, MetadataDialog, ArtifactPanel, AdvancedOptionsDialog +from gui_preprocessing import ( + FileQueuePanel, + ParameterPanel, + PlotDashboard, + MetadataDialog, + ArtifactPanel, + AdvancedOptionsDialog, +) from gui_postprocessing import PostProcessingPanel -from styles import APP_QSS +from styles import app_qss import numpy as np + +def _dock_area_to_int(value: object, fallback: int = 2) -> int: + """ + Convert Qt DockWidgetArea enum/flag objects (or stored values) to int safely. + Some PySide6 builds do not allow int(Qt enum) directly. + """ + try: + enum_value = getattr(value, "value", None) + if enum_value is not None: + return int(enum_value) + except Exception: + pass + try: + if isinstance(value, str): + v = value.strip().lower() + if "left" in v: + return 1 + if "right" in v: + return 2 + if "top" in v: + return 4 + if "bottom" in v: + return 8 + except Exception: + pass + try: + return int(value) + except Exception: + return int(fallback) + + +def _to_bool(value: object, default: bool = False) -> bool: + """ + Convert mixed QSettings bool payloads (bool/int/str) safely. + """ + if isinstance(value, bool): + return value + if value is None: + return bool(default) + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + v = value.strip().lower() + if v in {"1", "true", "yes", "y", "on"}: + return True + if v in {"0", "false", "no", "n", "off", ""}: + return False + return bool(default) + + +_DOCK_STATE_VERSION = 3 +# Dock snapshot keys are versioned because object names changed from pre_/post_ +# to pre./post. and old blobs are incompatible with restoreState. +_PRE_DOCK_STATE_KEY = "pre_main_dock_state_v4" +_POST_DOCK_STATE_KEY = "post_main_dock_state_v4" +_PRE_TAB_GROUPS_KEY = "pre_tab_groups_v1" +_PRE_DOCKAREA_STATE_KEY = "pre_dockarea_state_v1" +_PRE_DOCKAREA_VISIBLE_KEY = "pre_dockarea_visible_v1" +_PRE_DOCKAREA_ACTIVE_KEY = "pre_dockarea_active_v1" +_PRE_DOCK_PREFIX = "pre." +_POST_DOCK_PREFIX = "post." +_FORCE_FIXED_DOCK_LAYOUTS = False +_USE_PG_DOCKAREA_PRE_LAYOUT = True +_PRE_DOCKAREA_PRIMARY_ORDER = ("artifacts_list", "artifacts", "filtering", "baseline", "output", "export") +_PRE_DOCKAREA_OPTIONAL_ORDER = ("qc", "config") +_PRE_DOCKAREA_DEFAULT_VISIBLE = frozenset(_PRE_DOCKAREA_PRIMARY_ORDER) + +_LOG = logging.getLogger(__name__) + + +def _pyber_icon_path() -> str: + if getattr(sys, "frozen", False): + base_dir = str(getattr(sys, "_MEIPASS", "")) or os.path.dirname(sys.executable) + candidates = [ + os.path.join(base_dir, "assets", "pyBer_logo_big.png"), + os.path.join(os.path.dirname(sys.executable), "assets", "pyBer_logo_big.png"), + ] + else: + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + candidates = [os.path.join(base_dir, "assets", "pyBer_logo_big.png")] + + for path in candidates: + if os.path.isfile(path): + return path + return candidates[0] + + def _rolling_corr(x: np.ndarray, y: np.ndarray, win: int) -> Tuple[np.ndarray, np.ndarray]: x = np.asarray(x, float) y = np.asarray(y, float) @@ -191,7 +289,21 @@ class MainWindow(QtWidgets.QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle("Pyber - Fiber Photometry") - self.resize(1500, 900) + try: + icon_path = _pyber_icon_path() + if os.path.isfile(icon_path): + icon = QtGui.QIcon(icon_path) + if not icon.isNull(): + self.setWindowIcon(icon) + except Exception: + pass + self._set_initial_window_size() + self.setDockOptions( + QtWidgets.QMainWindow.DockOption.AllowNestedDocks + | QtWidgets.QMainWindow.DockOption.AllowTabbedDocks + | QtWidgets.QMainWindow.DockOption.AnimatedDocks + ) + self.setDockNestingEnabled(True) # Core self.processor = PhotometryProcessor() @@ -208,16 +320,51 @@ def __init__(self) -> None: self._metadata_by_key: Dict[Tuple[str, str], Dict[str, str]] = {} self._cutout_regions_by_key: Dict[Tuple[str, str], List[Tuple[float, float]]] = {} self._sections_by_key: Dict[Tuple[str, str], List[Dict[str, object]]] = {} + self._pending_box_region_by_key: Dict[Tuple[str, str], Tuple[float, float]] = {} self._last_processed: Dict[Tuple[str, str], ProcessedTrial] = {} self._advanced_dialog: Optional[AdvancedOptionsDialog] = None self._box_select_callback: Optional[Callable[[float, float], None]] = None self._last_artifact_params: Optional[Tuple[object, ...]] = None + self._section_docks: Dict[str, QtWidgets.QDockWidget] = {} + self._use_pg_dockarea_pre_layout: bool = bool(_USE_PG_DOCKAREA_PRE_LAYOUT) + self._pre_dockarea: Optional[DockArea] = None + self._pre_dockarea_docks: Dict[str, Dock] = {} + self._pre_section_scroll_hosts: Dict[str, QtWidgets.QScrollArea] = {} + self._pre_dockarea_fixed_layout_applied: bool = False + self._shortcuts: List[QtGui.QShortcut] = [] + self._last_opened_section: Optional[str] = None + self._section_popup_initialized: set[str] = set() + self._is_restoring_panel_layout: bool = False + # Prevent startup widget initialization from overwriting previously saved panel layout. + self._panel_layout_persistence_ready: bool = False + # Prevent temporary tab-switch popup visibility changes from overwriting persisted layout. + self._suspend_panel_layout_persistence: bool = False + self._pre_popups_hidden_by_tab_switch: bool = False + self._pre_section_visibility_before_tab_switch: Dict[str, bool] = {} + self._pre_section_state_before_tab_switch: Dict[str, Dict[str, object]] = {} + self._pre_artifact_visible_before_tab_switch: bool = False + self._pre_artifact_state_before_tab_switch: Dict[str, object] = {} + self._pre_advanced_visible_before_tab_switch: bool = False + self._pre_main_dock_state_before_tab_switch: Optional[QtCore.QByteArray] = None + self._pre_tab_groups_before_tab_switch: List[Dict[str, object]] = [] + self._pre_last_interacted_dock_name: Optional[str] = None + self._pre_snapshot_applied: bool = False + self._pre_snapshot_retry_attempts: int = 0 + self._pre_snapshot_retry_scheduled: bool = False + self._pre_snapshot_max_retries: int = 6 + self._post_docks_ready: bool = False + self._handling_main_tab_change: bool = False + self._pending_main_tab_index: Optional[int] = None + self._force_fixed_dock_layouts: bool = bool(_FORCE_FIXED_DOCK_LAYOUTS) + self._app_theme_mode: str = "dark" # Worker infra (stable) self._pool = QtCore.QThreadPool.globalInstance() self._job_counter = 0 self._latest_job_id = 0 + self._preview_preserve_view_pending: bool = False + self._preview_preserve_view_by_job: Dict[int, bool] = {} # Debounce self._preview_timer = QtCore.QTimer(self) @@ -227,67 +374,224 @@ def __init__(self) -> None: # Settings (persist folder + params) self.settings = QtCore.QSettings("FiberPhotometryApp", "DoricProcessor") + self._migrate_legacy_dock_state_settings() + # Load panel layout JSON into QSettings before UI is built. + self._load_panel_config_json_into_settings() self._build_ui() self._restore_settings() + self._panel_layout_persistence_ready = True # ---------------- UI ---------------- + def _set_initial_window_size(self) -> None: + """Choose a sensible non-fullscreen default size relative to the active screen.""" + screen = QtGui.QGuiApplication.primaryScreen() + if screen is None: + self.resize(1280, 780) + return + rect = screen.availableGeometry() + width = max(1024, min(1500, int(rect.width() * 0.86))) + height = max(680, min(900, int(rect.height() * 0.84))) + min_w = max(860, min(980, int(rect.width() * 0.65))) + min_h = max(560, min(640, int(rect.height() * 0.60))) + self.setMinimumSize(min_w, min_h) + self.resize(width, height) + def _build_ui(self) -> None: - self.setStyleSheet(APP_QSS) + self.setStyleSheet(app_qss(self._app_theme_mode)) self.tabs = QtWidgets.QTabWidget() self.setCentralWidget(self.tabs) + self._status_bar = QtWidgets.QStatusBar(self) + self.setStatusBar(self._status_bar) + self.btn_app_theme = QtWidgets.QPushButton("Theme") + self.btn_app_theme.setProperty("class", "blueSecondarySmall") + self.btn_app_theme.setSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed) + self.btn_app_theme.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + self.menu_app_theme = QtWidgets.QMenu(self.btn_app_theme) + self._app_theme_group = QtGui.QActionGroup(self) + self._app_theme_group.setExclusive(True) + self.act_app_theme_dark = self.menu_app_theme.addAction("Dark mode") + self.act_app_theme_dark.setCheckable(True) + self.act_app_theme_light = self.menu_app_theme.addAction("Light mode") + self.act_app_theme_light.setCheckable(True) + self._app_theme_group.addAction(self.act_app_theme_dark) + self._app_theme_group.addAction(self.act_app_theme_light) + self.act_app_theme_dark.setChecked(True) + self.btn_app_theme.setMenu(self.menu_app_theme) + + self._status_bar.addPermanentWidget(QtWidgets.QLabel("App theme")) + self._status_bar.addPermanentWidget(self.btn_app_theme) # Preprocessing tab - pre = QtWidgets.QWidget() - self.tabs.addTab(pre, "Preprocessing") - - self.file_panel = FileQueuePanel() - self.param_panel = ParameterPanel() - self.plots = PlotDashboard() - self.artifact_panel = ArtifactPanel() - - # Right artifact panel dock (hidden by default) - self.art_dock = QtWidgets.QDockWidget("Artifacts", self) - self.art_dock.setWidget(self.artifact_panel) - self.art_dock.setAllowedAreas(QtCore.Qt.DockWidgetArea.RightDockWidgetArea) - self.art_dock.setVisible(False) - self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, self.art_dock) - - # Left column: file + selection + buttons + parameters, all scrollable - left_container = QtWidgets.QWidget() - left_v = QtWidgets.QVBoxLayout(left_container) - left_v.setContentsMargins(0, 0, 0, 0) - left_v.setSpacing(10) - left_v.addWidget(self.file_panel) - left_v.addWidget(self.param_panel, stretch=1) - - left_scroll = QtWidgets.QScrollArea() - left_scroll.setWidgetResizable(True) - left_scroll.setMinimumWidth(160) - left_scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) - left_scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) - left_scroll.setWidget(left_container) - - # Make panels resizable: left | plots (right dock already resizable by Qt) - splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) - splitter.addWidget(left_scroll) - splitter.addWidget(self.plots) - splitter.setStretchFactor(0, 0) - splitter.setStretchFactor(1, 1) - # Set initial sizes: smaller left panel, larger visualization - splitter.setSizes([160, 1540]) # Narrower left panel to favor plots - # Connect splitter size changes to save settings - splitter.splitterMoved.connect(self._save_splitter_sizes) - - pre_layout = QtWidgets.QVBoxLayout(pre) + self.pre_tab = QtWidgets.QWidget() + self.tabs.addTab(self.pre_tab, "Preprocessing") + + self.file_panel = FileQueuePanel(self.pre_tab) + self.param_panel = ParameterPanel(self.pre_tab) + self.param_panel.setVisible(False) + self.plots = PlotDashboard(self.pre_tab) + self.artifact_panel = ArtifactPanel(self.pre_tab) + + self.art_dock: Optional[QtWidgets.QDockWidget] = None + # Legacy artifact list dock (kept for non-DockArea preprocessing mode). + if not self._use_pg_dockarea_pre_layout: + self.art_dock = QtWidgets.QDockWidget("Artifact list", self) + self.art_dock.setObjectName("pre.artifact.dock") + self.art_dock.setWidget(self.artifact_panel) + self.art_dock.setAllowedAreas(QtCore.Qt.DockWidgetArea.RightDockWidgetArea) + self.art_dock.setVisible(False) + self.art_dock.visibilityChanged.connect(lambda *_: self._save_panel_layout_state()) + self.art_dock.topLevelChanged.connect(lambda *_: self._save_panel_layout_state()) + self.art_dock.dockLocationChanged.connect(lambda *_: self._save_panel_layout_state()) + self.art_dock.installEventFilter(self) + self.artifact_panel.installEventFilter(self) + self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, self.art_dock) + + # Left pane: data browser + self.file_panel.setMinimumWidth(260) + self.file_panel.setMaximumWidth(340) + self.file_panel.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Expanding) + + # Center pane: workflow toolbar + plots + self.btn_workflow_load = QtWidgets.QPushButton("Load") + self.btn_workflow_load.setProperty("class", "blueSecondarySmall") + self.menu_workflow_load = QtWidgets.QMenu(self.btn_workflow_load) + self.act_open_file = self.menu_workflow_load.addAction("Open File...") + self.act_add_folder = self.menu_workflow_load.addAction("Add Folder...") + self.menu_workflow_load_recent = self.menu_workflow_load.addMenu("Load Recent") + self.menu_workflow_load_recent.aboutToShow.connect(self._refresh_recent_preprocessing_menu) + self.menu_workflow_load.addSeparator() + self.act_focus_data = self.menu_workflow_load.addAction("Focus Data Browser") + self.btn_workflow_load.setMenu(self.menu_workflow_load) + + self.btn_workflow_artifacts = QtWidgets.QPushButton("Detected artifacts") + self.btn_workflow_qc = QtWidgets.QPushButton("QC") + self.btn_workflow_export = QtWidgets.QPushButton("Export") + self.btn_plot_style = QtWidgets.QPushButton("Plot style") + self.btn_toggle_data = QtWidgets.QPushButton("Data") + self.btn_toggle_data.setCheckable(True) + self.btn_toggle_data.setChecked(True) + self.btn_toggle_data.setProperty("class", "blueSecondarySmall") + self.btn_workflow_export.setProperty("class", "bluePrimarySmall") + for b in ( + self.btn_toggle_data, + self.btn_workflow_load, + self.btn_workflow_artifacts, + self.btn_workflow_qc, + self.btn_workflow_export, + self.btn_plot_style, + ): + b.setSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed) + b.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + for b in (self.btn_workflow_artifacts, self.btn_workflow_qc, self.btn_plot_style): + b.setProperty("class", "blueSecondarySmall") + + self.menu_plot_style = QtWidgets.QMenu(self.btn_plot_style) + self._plot_bg_group = QtGui.QActionGroup(self) + self._plot_bg_group.setExclusive(True) + self.act_plot_bg_dark = self.menu_plot_style.addAction("Dark background") + self.act_plot_bg_dark.setCheckable(True) + self.act_plot_bg_white = self.menu_plot_style.addAction("White background") + self.act_plot_bg_white.setCheckable(True) + self._plot_bg_group.addAction(self.act_plot_bg_dark) + self._plot_bg_group.addAction(self.act_plot_bg_white) + self.menu_plot_style.addSeparator() + self.act_plot_grid = self.menu_plot_style.addAction("Show grid") + self.act_plot_grid.setCheckable(True) + self.act_plot_bg_dark.setChecked(True) + self.act_plot_grid.setChecked(True) + self.btn_plot_style.setMenu(self.menu_plot_style) + + # Inline parameter section buttons (same row as workflow actions). + self.btn_section_artifacts_list = QtWidgets.QPushButton("Artifact list") + self.btn_section_artifacts = QtWidgets.QPushButton("Artifact setup") + self.btn_section_filtering = QtWidgets.QPushButton("Filtering") + self.btn_section_baseline = QtWidgets.QPushButton("Baseline") + self.btn_section_output = QtWidgets.QPushButton("Output") + self.btn_section_qc = QtWidgets.QPushButton("QC") + self.btn_section_export = QtWidgets.QPushButton("Export") + self.btn_section_config = QtWidgets.QPushButton("Configuration") + self._section_buttons: Dict[str, QtWidgets.QPushButton] = { + "artifacts_list": self.btn_section_artifacts_list, + "artifacts": self.btn_section_artifacts, + "filtering": self.btn_section_filtering, + "baseline": self.btn_section_baseline, + "output": self.btn_section_output, + "export": self.btn_section_export, + "qc": self.btn_section_qc, + "config": self.btn_section_config, + } + for btn in self._section_buttons.values(): + btn.setCheckable(True) + btn.setProperty("class", "blueSecondarySmall") + btn.setSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed) + btn.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + + workflow_row = QtWidgets.QHBoxLayout() + workflow_row.setContentsMargins(0, 0, 0, 0) + workflow_row.setSpacing(6) + workflow_row.addWidget(self.btn_toggle_data) + workflow_row.addWidget(self.btn_workflow_load) + workflow_row.addWidget(self.btn_workflow_artifacts) + workflow_row.addWidget(self.btn_workflow_qc) + workflow_row.addWidget(self.btn_workflow_export) + workflow_row.addWidget(self.btn_plot_style) + workflow_row.addStretch(1) + + section_row = QtWidgets.QHBoxLayout() + section_row.setContentsMargins(0, 0, 0, 0) + section_row.setSpacing(6) + section_row.addWidget(QtWidgets.QLabel("Parameters:")) + for btn in self._section_buttons.values(): + section_row.addWidget(btn) + section_row.addStretch(1) + + center_widget = QtWidgets.QWidget() + center_v = QtWidgets.QVBoxLayout(center_widget) + center_v.setContentsMargins(0, 0, 0, 0) + center_v.setSpacing(6) + center_v.addLayout(workflow_row) + center_v.addLayout(section_row) + center_v.addWidget(self.plots, stretch=1) + + # Main splitter: data panel + visuals. Parameter popups are floating by default. + self.pre_splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + self.pre_splitter.setObjectName("preprocessing_splitter") + self.pre_splitter.addWidget(self.file_panel) + self.pre_splitter.addWidget(center_widget) + if self._use_pg_dockarea_pre_layout: + self._pre_dockarea = DockArea() + self.pre_splitter.addWidget(self._pre_dockarea) + self.pre_splitter.setChildrenCollapsible(False) + self.pre_splitter.setStretchFactor(0, 0) + self.pre_splitter.setStretchFactor(1, 1) + if self._use_pg_dockarea_pre_layout: + self.pre_splitter.setStretchFactor(2, 0) + self.pre_splitter.setSizes([320, 1100, 520]) + else: + self.pre_splitter.setSizes([350, 1350]) + self.pre_splitter.splitterMoved.connect(self._save_splitter_sizes) + + pre_layout = QtWidgets.QVBoxLayout(self.pre_tab) pre_layout.setContentsMargins(10, 10, 10, 10) - pre_layout.addWidget(splitter) + pre_layout.addWidget(self.pre_splitter) # Postprocessing tab self.post_tab = PostProcessingPanel() - self.tabs.addTab(self.post_tab, "Post Processing") + if hasattr(self.post_tab, "set_app_theme_mode"): + try: + self.post_tab.set_app_theme_mode(self._app_theme_mode) + except Exception: + pass + if hasattr(self.post_tab, "set_force_fixed_default_layout"): + try: + self.post_tab.set_force_fixed_default_layout(self._force_fixed_dock_layouts) + except Exception: + pass + self.tabs.addTab(self.post_tab, "Postprocessing") + self.post_tab.statusUpdate.connect(self._show_status_message) # Wiring - file panel self.file_panel.openFileRequested.connect(self._open_files_dialog) @@ -305,8 +609,40 @@ def _build_ui(self) -> None: self.file_panel.qcRequested.connect(self._run_qc_dialog) self.file_panel.batchQcRequested.connect(self._run_batch_qc) - # Parameters -> debounce preview + # Parameters: changes and actions self.param_panel.paramsChanged.connect(self._on_params_changed) + self.param_panel.paramsChanged.connect(self._update_export_summary_label) + self.param_panel.previewRequested.connect(self._trigger_preview) + self.param_panel.metadataRequested.connect(self._edit_metadata_for_current) + self.param_panel.exportRequested.connect(self._export_selected_or_all) + self.param_panel.artifactsRequested.connect(self._toggle_artifacts_panel) + self.param_panel.artifactOverlayToggled.connect(self._on_artifact_overlay_toggled) + self.param_panel.advancedOptionsRequested.connect(self._open_advanced_options) + self.param_panel.qcRequested.connect(self._run_qc_dialog) + self.param_panel.batchQcRequested.connect(self._run_batch_qc) + self.param_panel.set_config_state_hooks( + self._export_preprocessing_ui_state_for_config, + self._import_preprocessing_ui_state_from_config, + ) + + # Workflow toolbar + self.act_open_file.triggered.connect(self._open_files_dialog) + self.act_add_folder.triggered.connect(self._open_folder_dialog) + self.act_focus_data.triggered.connect(self._focus_data_browser) + self.act_plot_bg_dark.triggered.connect(self._on_pre_plot_style_changed) + self.act_plot_bg_white.triggered.connect(self._on_pre_plot_style_changed) + self.act_plot_grid.toggled.connect(self._on_pre_plot_style_changed) + self.act_app_theme_dark.triggered.connect(self._on_app_theme_changed) + self.act_app_theme_light.triggered.connect(self._on_app_theme_changed) + self.btn_toggle_data.toggled.connect(self._set_data_panel_visible) + self.btn_workflow_artifacts.clicked.connect(self._toggle_artifacts_panel) + self.btn_workflow_qc.clicked.connect(self._run_qc_dialog) + self.btn_workflow_export.clicked.connect(self._export_selected_or_all) + + # Section popup controls + self._setup_section_popups() + for key, btn in self._section_buttons.items(): + btn.toggled.connect(lambda checked, section_key=key: self._toggle_section_popup(section_key, checked)) # Plot sync self.plots.xRangeChanged.connect(self.plots.set_xrange_all) @@ -317,6 +653,8 @@ def _build_ui(self) -> None: self.plots.clearManualRegionsRequested.connect(self._clear_manual_regions_current) self.plots.showArtifactsRequested.connect(self._toggle_artifacts_panel) self.plots.boxSelectionCleared.connect(self._cancel_box_select_request) + self.plots.boxSelectionContextRequested.connect(self._show_box_selection_context_menu) + self.plots.artifactThresholdsToggled.connect(self._on_artifact_thresholds_toggled) self.artifact_panel.regionsChanged.connect(self._artifact_regions_changed) self.artifact_panel.selectionChanged.connect(self.plots.highlight_artifact_regions) @@ -325,126 +663,2709 @@ def _build_ui(self) -> None: self.post_tab.requestCurrentProcessed.connect(self._post_get_current_processed) self.post_tab.requestDioList.connect(self._post_get_current_dio_list) self.post_tab.requestDioData.connect(self._post_get_dio_data_for_path) + self.tabs.currentChanged.connect(self._on_main_tab_changed) + self._init_shortcuts() + self.plots.set_artifact_overlay_visible(self.param_panel.artifact_overlay_visible()) + self.plots.set_artifact_thresholds_visible(True) + self._update_plot_status() self.setAcceptDrops(True) - # ---------------- Settings persistence ---------------- + def _setup_section_popups(self) -> None: + """Create preprocessing section panels using DockArea or legacy floating docks.""" + if self._use_pg_dockarea_pre_layout and self._pre_dockarea_docks: + return + if (not self._use_pg_dockarea_pre_layout) and self._section_docks: + return + + # Move section cards out of the hidden ParameterPanel container and into docks. + root_layout = self.param_panel.layout() + section_cards = [ + self.param_panel.card_artifacts, + self.param_panel.card_filtering, + self.param_panel.card_baseline, + self.param_panel.card_output, + self.param_panel.card_actions, + ] + if root_layout is not None: + for w in section_cards: + root_layout.removeWidget(w) + w.setParent(None) + self.param_panel.card_actions.setVisible(False) + + section_widgets: Dict[str, QtWidgets.QWidget] = { + "artifacts_list": self.artifact_panel, + "artifacts": self.param_panel.card_artifacts, + "filtering": self.param_panel.card_filtering, + "baseline": self.param_panel.card_baseline, + "output": self.param_panel.card_output, + "export": self._build_export_actions_widget(), + "qc": self._build_qc_actions_widget(), + "config": self._build_config_actions_widget(), + } + section_titles: Dict[str, str] = { + "artifacts_list": "Artifact list", + "artifacts": "Artifact setup", + "filtering": "Filtering", + "baseline": "Baseline", + "output": "Output", + "export": "Export", + "qc": "QC", + "config": "Configuration", + } + + if self._use_pg_dockarea_pre_layout: + if self._pre_dockarea is None: + return + for key, title in section_titles.items(): + widget = section_widgets[key] + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + widget.setMinimumSize(0, 0) + widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Preferred) + scroll.setWidget(widget) + self._pre_section_scroll_hosts[key] = scroll + + dock = Dock(title, area=self._pre_dockarea, closable=True) + dock.setObjectName(f"pre.da.{key}.dock") + dock.addWidget(scroll) + self._lock_pre_pg_dock_interactions(dock) + try: + dock.sigClosed.connect(lambda *_, section_key=key: self._on_pre_dockarea_dock_closed(section_key)) + except Exception: + pass + self._pre_dockarea_docks[key] = dock + + self._restore_pre_dockarea_layout_state() + self._pre_dockarea_fixed_layout_applied = False + return + + for key, title in section_titles.items(): + widget = section_widgets[key] + dock = QtWidgets.QDockWidget(title, self) + dock.setObjectName(f"pre.{key}.dock") + dock.setAllowedAreas(QtCore.Qt.DockWidgetArea.AllDockWidgetAreas) + dock.setFeatures( + QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetClosable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetMovable + | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetFloatable + ) + dock.setWidget(widget) + dock.visibilityChanged.connect(lambda visible, section_key=key: self._on_section_dock_visibility(section_key, visible)) + dock.topLevelChanged.connect(lambda *_: self._save_panel_layout_state()) + dock.dockLocationChanged.connect(lambda *_: self._save_panel_layout_state()) + # Register with main window once; each popup opens floating by default. + self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, dock) + dock.setFloating(True) + dock.hide() + dock.installEventFilter(self) + widget.installEventFilter(self) + self._section_docks[key] = dock + + def _pre_dockarea_dock(self, key: str) -> Optional[Dock]: + return self._pre_dockarea_docks.get(key) + + def _pre_dockarea_ordered_keys(self) -> List[str]: + ordered = list(_PRE_DOCKAREA_PRIMARY_ORDER) + list(_PRE_DOCKAREA_OPTIONAL_ORDER) + return [key for key in ordered if self._pre_dockarea_dock(key) is not None] + + def _pre_dockarea_default_visible_map(self) -> Dict[str, bool]: + return {key: (key in _PRE_DOCKAREA_DEFAULT_VISIBLE) for key in self._pre_dockarea_docks.keys()} + + def _lock_pre_pg_dock_interactions(self, dock: Dock) -> None: + label = getattr(dock, "label", None) + if label is None: + return + if not self._force_fixed_dock_layouts: + self._style_pg_dock_label_buttons(dock, label) + return + if bool(getattr(label, "_pyber_fixed_interaction_lock", False)): + return + + def _ignore_drag(event: QtGui.QMouseEvent) -> None: + event.ignore() + + def _ignore_double_click(event: QtGui.QMouseEvent) -> None: + event.accept() + + try: + label.mouseMoveEvent = _ignore_drag + label.mouseDoubleClickEvent = _ignore_double_click + label.setCursor(QtCore.Qt.CursorShape.ArrowCursor) + label._pyber_fixed_interaction_lock = True + except Exception: + pass + self._style_pg_dock_label_buttons(dock, label) + + def _style_pg_dock_label_buttons(self, dock: Dock, label: object) -> None: + if label is None: + return + try: + buttons = label.findChildren(QtWidgets.QToolButton) + except Exception: + buttons = [] + for btn in buttons: + try: + btn.setText("x") + btn.setIcon(QtGui.QIcon()) + btn.setAutoRaise(True) + btn.setFixedSize(13, 13) + btn.setToolTip("Close") + if not bool(btn.property("_pyber_hide_wired")): + try: + btn.clicked.disconnect() + except Exception: + pass + btn.clicked.connect(lambda _checked=False, section_dock=dock: self._hide_pre_dockarea_dock(section_dock)) + btn.setProperty("_pyber_hide_wired", True) + btn.setStyleSheet( + "QToolButton {" + " background: transparent;" + " color: #f3f5f8;" + " border: none;" + " padding: 0px;" + " margin: 0px;" + " font-size: 8pt;" + " font-weight: 700;" + " }" + "QToolButton:hover {" + " background: transparent;" + " color: #ffffff;" + " border: none;" + " }" + ) + except Exception: + continue + + def _hide_pre_dockarea_dock(self, dock: Dock) -> None: + if dock is None: + return + try: + dock.hide() + except Exception: + return + for key, candidate in self._pre_dockarea_docks.items(): + if candidate is dock: + self._set_section_button_checked(key, False) + if self._last_opened_section == key: + self._last_opened_section = None + break + self._save_panel_layout_state() + + def _arrange_pre_dockarea_default(self) -> None: + if self._pre_dockarea is None: + return + ordered = self._pre_dockarea_ordered_keys() + root = self._pre_dockarea_dock("artifacts_list") + if root is None and ordered: + root = self._pre_dockarea_dock(ordered[0]) + if root is None: + return + self._pre_dockarea.addDock(root, "right") + for key in ordered: + dock = self._pre_dockarea_dock(key) + if dock is not None and dock is not root: + self._pre_dockarea.addDock(dock, "above", root) + + def _pre_dockarea_active_key(self) -> Optional[str]: + active = self._last_opened_section + if isinstance(active, str) and active in self._pre_dockarea_docks: + return active + for key in self._pre_dockarea_ordered_keys(): + dock = self._pre_dockarea_dock(key) + if dock is not None and dock.isVisible(): + return key + return None + + def _set_pre_dockarea_visible(self, key: str, visible: bool) -> None: + dock = self._pre_dockarea_dock(key) + if dock is None: + return + if visible: + dock.show() + else: + dock.hide() + + def _save_pre_dockarea_layout_state(self) -> None: + if self._pre_dockarea is None: + return + try: + state = dict(self._pre_dockarea.saveState() or {}) + except Exception: + state = {} + visible = {key: bool(dock.isVisible()) for key, dock in self._pre_dockarea_docks.items()} + active = self._pre_dockarea_active_key() or "" + try: + self.settings.setValue(_PRE_DOCKAREA_STATE_KEY, json.dumps(state)) + self.settings.setValue(_PRE_DOCKAREA_VISIBLE_KEY, json.dumps(visible)) + self.settings.setValue(_PRE_DOCKAREA_ACTIVE_KEY, active) + self.settings.remove(_PRE_DOCK_STATE_KEY) + self.settings.remove(_PRE_TAB_GROUPS_KEY) + except Exception: + pass + + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + for key, dock in self._pre_dockarea_docks.items(): + if key == "artifacts_list": + continue + try: + base = f"pre_section_docks/{key}" + self.settings.setValue(f"{base}/visible", bool(dock.isVisible())) + self.settings.setValue(f"{base}/floating", False) + self.settings.setValue(f"{base}/area", right_i) + except Exception: + continue + try: + art_base = "pre_artifact_dock_state" + art_vis = bool(visible.get("artifacts_list", False)) + self.settings.setValue(f"{art_base}/visible", art_vis) + self.settings.setValue(f"{art_base}/floating", False) + self.settings.setValue(f"{art_base}/area", right_i) + except Exception: + pass + + def _restore_pre_dockarea_layout_state(self) -> None: + if self._pre_dockarea is None or not self._pre_dockarea_docks: + return + self._pre_dockarea_fixed_layout_applied = False + self._arrange_pre_dockarea_default() + + raw_state = self.settings.value(_PRE_DOCKAREA_STATE_KEY, "") + try: + if isinstance(raw_state, str) and raw_state.strip(): + parsed = json.loads(raw_state) + if isinstance(parsed, dict): + self._pre_dockarea.restoreState(parsed, missing="ignore", extra="bottom") + except Exception: + pass + + visible_map: Dict[str, bool] = {} + raw_vis = self.settings.value(_PRE_DOCKAREA_VISIBLE_KEY, "") + try: + if isinstance(raw_vis, str) and raw_vis.strip(): + parsed = json.loads(raw_vis) + if isinstance(parsed, dict): + visible_map = {str(k): bool(v) for k, v in parsed.items()} + except Exception: + visible_map = {} + + if not visible_map: + for key in self._pre_dockarea_docks.keys(): + if key == "artifacts_list": + raw = self.settings.value("pre_artifact_dock_state/visible", None) + if raw is not None: + visible_map[key] = _to_bool(raw, False) + continue + raw = self.settings.value(f"pre_section_docks/{key}/visible", None) + if raw is not None: + visible_map[key] = _to_bool(raw, False) + if not visible_map: + visible_map = self._pre_dockarea_default_visible_map() + + for key in self._pre_dockarea_docks.keys(): + self._set_pre_dockarea_visible(key, bool(visible_map.get(key, False))) + + active = str(self.settings.value(_PRE_DOCKAREA_ACTIVE_KEY, "artifacts_list") or "artifacts_list") + active_dock = self._pre_dockarea_dock(active) + if active_dock is not None and active_dock.isVisible(): + try: + active_dock.raiseDock() + except Exception: + pass + if active in self._section_buttons: + self._last_opened_section = active + else: + self._last_opened_section = None + for key in self._pre_dockarea_ordered_keys(): + dock = self._pre_dockarea_dock(key) + if dock is not None and dock.isVisible(): + try: + dock.raiseDock() + except Exception: + pass + self._last_opened_section = key + break + + self._sync_section_button_states_from_docks() + + def _apply_pre_fixed_dockarea_layout(self) -> None: + if self._pre_dockarea is None or not self._pre_dockarea_docks: + return + visible_map = {key: bool(dock.isVisible()) for key, dock in self._pre_dockarea_docks.items()} + if not any(visible_map.values()): + visible_map = self._pre_dockarea_default_visible_map() + self._arrange_pre_dockarea_default() + for key in self._pre_dockarea_docks.keys(): + self._set_pre_dockarea_visible(key, bool(visible_map.get(key, False))) + + active = self._last_opened_section if bool(visible_map.get(self._last_opened_section or "", False)) else None + if active is None: + for key in self._pre_dockarea_ordered_keys(): + if bool(visible_map.get(key, False)): + active = key + break + dock = self._pre_dockarea_dock(active) if active else None + if dock is not None and dock.isVisible(): + try: + dock.raiseDock() + except Exception: + pass + self._last_opened_section = active + self._sync_section_button_states_from_docks() + self._save_pre_dockarea_layout_state() + self._pre_dockarea_fixed_layout_applied = True + + def _on_pre_dockarea_dock_closed(self, key: str) -> None: + if key in self._section_buttons: + self._set_section_button_checked(key, False) + if self._last_opened_section == key: + self._last_opened_section = None + self._save_panel_layout_state() + + def _build_qc_actions_widget(self) -> QtWidgets.QWidget: + panel = QtWidgets.QWidget() + v = QtWidgets.QVBoxLayout(panel) + v.setContentsMargins(8, 8, 8, 8) + v.setSpacing(6) + self.param_panel.btn_qc.setProperty("class", "blueSecondarySmall") + self.param_panel.btn_qc_batch.setProperty("class", "blueSecondarySmall") + self.param_panel.btn_artifacts_panel.setProperty("class", "blueSecondarySmall") + v.addWidget(self.param_panel.btn_qc) + v.addWidget(self.param_panel.btn_qc_batch) + v.addWidget(self.param_panel.btn_artifacts_panel) + v.addWidget(self.param_panel.lbl_fs) + v.addStretch(1) + return panel + + def _build_export_actions_widget(self) -> QtWidgets.QWidget: + panel = QtWidgets.QWidget() + v = QtWidgets.QVBoxLayout(panel) + v.setContentsMargins(8, 8, 8, 8) + v.setSpacing(6) + self.lbl_export_summary = QtWidgets.QLabel("") + self.lbl_export_summary.setWordWrap(True) + self.lbl_export_summary.setProperty("class", "hint") + v.addWidget(self.lbl_export_summary) + self.param_panel.btn_export.setProperty("class", "bluePrimarySmall") + v.addWidget(self.param_panel.btn_export) + v.addStretch(1) + self._update_export_summary_label() + return panel + + def _build_config_actions_widget(self) -> QtWidgets.QWidget: + panel = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(panel) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + row = QtWidgets.QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(6) + self.param_panel.btn_metadata.setProperty("class", "blueSecondarySmall") + self.param_panel.btn_save_config.setProperty("class", "blueSecondarySmall") + self.param_panel.btn_load_config.setProperty("class", "blueSecondarySmall") + for btn in ( + self.param_panel.btn_metadata, + self.param_panel.btn_save_config, + self.param_panel.btn_load_config, + ): + btn.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Fixed) + btn.setMinimumWidth(90) + btn.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + row.addWidget(btn) + layout.addLayout(row) + if hasattr(self.param_panel, "export_options_group"): + self.param_panel.export_options_group.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + layout.addWidget(self.param_panel.export_options_group) + layout.addStretch(1) + return panel + + def _update_export_summary_label(self) -> None: + if hasattr(self, "lbl_export_summary") and self.lbl_export_summary is not None: + self.lbl_export_summary.setText(self.param_panel.export_selection_summary()) + + def _set_section_button_checked(self, key: str, checked: bool) -> None: + btn = self._section_buttons.get(key) + if btn is None: + return + btn.blockSignals(True) + btn.setChecked(bool(checked)) + btn.blockSignals(False) + + def _toggle_section_popup(self, key: str, checked: bool) -> None: + if self._use_pg_dockarea_pre_layout: + dock = self._pre_dockarea_dock(key) + if dock is None: + return + if checked: + dock.show() + try: + dock.raiseDock() + except Exception: + pass + scroll = self._pre_section_scroll_hosts.get(key) + self._focus_first_editable(scroll.widget() if scroll is not None else None) + self._last_opened_section = key + else: + dock.hide() + self._save_panel_layout_state() + return + dock = self._section_docks.get(key) + if dock is None: + return + if checked: + if key not in self._section_popup_initialized or not self._is_popup_on_screen(dock): + dock.setFloating(True) + self._position_section_popup(dock) + self._section_popup_initialized.add(key) + dock.show() + dock.raise_() + dock.activateWindow() + self._focus_first_editable(dock.widget()) + self._last_opened_section = key + else: + dock.hide() + + def _on_section_dock_visibility(self, key: str, visible: bool) -> None: + if self._use_pg_dockarea_pre_layout: + if key in self._section_buttons: + self._set_section_button_checked(key, visible) + if not visible and self._last_opened_section == key: + self._last_opened_section = None + if visible: + self._last_opened_section = key + self._save_panel_layout_state() + return + self._set_section_button_checked(key, visible) + if not visible and self._last_opened_section == key: + self._last_opened_section = None + if visible: + self._last_opened_section = key + self._save_panel_layout_state() + + def _position_section_popup(self, dock: QtWidgets.QDockWidget) -> None: + """Place floating popups near the window while keeping them inside visible screen bounds.""" + geom = self.frameGeometry() + screen_rect = self._active_screen_geometry() + + pref_w, pref_h = self._default_popup_size(dock) + max_w = max(320, screen_rect.width() - 40) + max_h = max(260, screen_rect.height() - 40) + width = min(pref_w, max_w) + height = min(pref_h, max_h) + + # Prefer right side of main window, then fall back to left, then clamp. + x_right = geom.x() + geom.width() + 12 + x_left = geom.x() - width - 12 + y_pref = geom.y() + 60 + + x_min = screen_rect.x() + 10 + y_min = screen_rect.y() + 10 + x_max = screen_rect.x() + max(10, screen_rect.width() - width - 10) + y_max = screen_rect.y() + max(10, screen_rect.height() - height - 10) + + if x_right <= x_max: + x = x_right + elif x_left >= x_min: + x = x_left + else: + x = x_max + y = min(max(y_pref, y_min), y_max) + + dock.resize(width, height) + dock.move(int(x), int(y)) + + def _default_popup_size(self, dock: QtWidgets.QDockWidget) -> Tuple[int, int]: + """Compact default popup sizes, with smaller heights per section.""" + geom = self.frameGeometry() + name = str(dock.objectName() or "") + if name.startswith(f"{_PRE_DOCK_PREFIX}") and name.endswith(".dock"): + key = name[len(_PRE_DOCK_PREFIX):-len(".dock")] + else: + key = name + height_by_section = { + "artifacts": 380, + "filtering": 340, + "baseline": 380, + "output": 410, + "qc": 300, + "export": 270, + "config": 280, + } + pref_h = int(height_by_section.get(key, 340)) + pref_w = max(360, int(geom.width() * 0.24)) + return pref_w, pref_h + + def _active_screen_geometry(self) -> QtCore.QRect: + handle = self.windowHandle() + screen = handle.screen() if handle else None + if screen is None: + screen = QtGui.QGuiApplication.screenAt(self.frameGeometry().center()) + if screen is None: + screen = QtGui.QGuiApplication.primaryScreen() + if screen is None: + return QtCore.QRect(0, 0, 1920, 1080) + return screen.availableGeometry() + + def _is_popup_on_screen(self, dock: QtWidgets.QDockWidget) -> bool: + rect = dock.frameGeometry() + if rect.width() <= 0 or rect.height() <= 0: + return False + for screen in QtGui.QGuiApplication.screens(): + if screen.availableGeometry().intersects(rect): + return True + return False + + def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: + try: + if event.type() in (QtCore.QEvent.Type.MouseButtonPress, QtCore.QEvent.Type.FocusIn): + dock: Optional[QtWidgets.QDockWidget] = None + if isinstance(obj, QtWidgets.QDockWidget): + dock = obj + elif isinstance(obj, QtWidgets.QWidget): + parent = obj + while parent is not None and not isinstance(parent, QtWidgets.QDockWidget): + parent = parent.parentWidget() + if isinstance(parent, QtWidgets.QDockWidget): + dock = parent + if dock is not None: + name = str(dock.objectName() or "") + if name.startswith(_PRE_DOCK_PREFIX): + self._pre_last_interacted_dock_name = name + except Exception: + pass + return super().eventFilter(obj, event) + + def getPreDockWidgets(self) -> List[QtWidgets.QDockWidget]: + if self._use_pg_dockarea_pre_layout: + docks: List[QtWidgets.QDockWidget] = [] + if isinstance(self.art_dock, QtWidgets.QDockWidget): + docks.append(self.art_dock) + else: + docks = list(self._section_docks.values()) + if isinstance(self.art_dock, QtWidgets.QDockWidget): + docks.append(self.art_dock) + seen: set[int] = set() + out: List[QtWidgets.QDockWidget] = [] + for dock in docks: + did = id(dock) + if did in seen: + continue + seen.add(did) + out.append(dock) + return out + + def getPostDockWidgets(self) -> List[QtWidgets.QDockWidget]: + docks: List[QtWidgets.QDockWidget] = [] + try: + self.post_tab.ensure_section_popups_initialized() + docks = list(self.post_tab.get_section_dock_widgets()) + except Exception: + docks = [] + if docks: + return docks + # Fallback for legacy sessions where post docks may already exist but not registered. + return [ + d for d in self.findChildren(QtWidgets.QDockWidget) + if str(d.objectName() or "").startswith(_POST_DOCK_PREFIX) + ] + + def _hide_dock_widgets(self, docks: List[QtWidgets.QDockWidget], *, remove: bool = True) -> None: + for dock in docks: + if dock is None: + continue + try: + dock.hide() + except Exception: + pass + if remove: + try: + self.removeDockWidget(dock) + except Exception: + pass + + def hideOtherTabDocks(self, tab_name: str) -> None: + if tab_name == "pre": + remove_post = not self._force_fixed_dock_layouts + self._hide_dock_widgets(self.getPostDockWidgets(), remove=remove_post) + elif tab_name == "post": + self._hide_dock_widgets(self.getPreDockWidgets(), remove=True) + # Final guard: keep post dock registry initialized before post restore paths run. + try: + self.post_tab.ensure_section_popups_initialized() + except Exception: + pass + + def _enforce_only_tab_docks_visible(self, tab_name: str) -> None: + self.hideOtherTabDocks(tab_name) + + def captureDockSnapshotForTab(self, tab_name: str) -> Optional[QtCore.QByteArray]: + """ + Capture a tab-scoped dock snapshot. Other-tab docks are hidden first so + QMainWindow.saveState() cannot serialize mixed-tab layouts. + """ + if tab_name not in {"pre", "post"}: + return None + try: + self.hideOtherTabDocks(tab_name) + QtWidgets.QApplication.processEvents(QtCore.QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents) + state = self.saveState(_DOCK_STATE_VERSION) + self.hideOtherTabDocks(tab_name) + if state is None or state.isEmpty(): + _LOG.warning("Dock snapshot capture returned empty state for tab=%s", tab_name) + return None + if not self._is_tab_scoped_dock_state(tab_name, state): + _LOG.warning("Discarding contaminated dock snapshot for tab=%s", tab_name) + return None + return state + except Exception: + _LOG.exception("Dock snapshot capture failed for tab=%s", tab_name) + return None + + def restoreDockSnapshotForTab(self, tab_name: str, state: QtCore.QByteArray) -> bool: + """ + Restore a tab-scoped dock snapshot with hard post-restore enforcement so + foreign tab docks cannot leak back into the active tab. + """ + if tab_name not in {"pre", "post"}: + return False + if state is None or state.isEmpty(): + return False + if not self._is_tab_scoped_dock_state(tab_name, state): + _LOG.warning("Rejecting invalid/contaminated dock snapshot for tab=%s", tab_name) + try: + if tab_name == "pre": + self.settings.remove(_PRE_DOCK_STATE_KEY) + else: + self.settings.remove(_POST_DOCK_STATE_KEY) + except Exception: + pass + return False + try: + self.hideOtherTabDocks(tab_name) + ok = bool(self.restoreState(state, _DOCK_STATE_VERSION)) + self.hideOtherTabDocks(tab_name) + self._enforce_only_tab_docks_visible(tab_name) + if not ok: + _LOG.warning("Dock snapshot restore failed for tab=%s", tab_name) + else: + _LOG.info("Dock snapshot restore succeeded for tab=%s", tab_name) + return ok + except Exception: + _LOG.exception("Dock snapshot restore crashed for tab=%s", tab_name) + return False + + def _capture_pre_tab_groups_state(self) -> List[Dict[str, object]]: + """ + Capture tabified pre-dock groups + active tab candidate for fallback restore. + """ + docks = [d for d in self.getPreDockWidgets() if not d.isFloating()] + by_name: Dict[str, QtWidgets.QDockWidget] = { + str(d.objectName()): d for d in docks if str(d.objectName() or "") + } + groups: List[Dict[str, object]] = [] + visited: set[str] = set() + for dock in docks: + name = str(dock.objectName() or "") + if not name or name in visited: + continue + members = [dock] + [d for d in self.tabifiedDockWidgets(dock) if d in docks] + member_names = sorted({str(d.objectName() or "") for d in members if str(d.objectName() or "")}) + if len(member_names) < 2: + continue + visited.update(member_names) + active = "" + if self._pre_last_interacted_dock_name and self._pre_last_interacted_dock_name in member_names: + active = self._pre_last_interacted_dock_name + if not active: + for n in member_names: + d = by_name.get(n) + if d is not None and d.isVisible(): + active = n + break + if not active: + active = member_names[0] + groups.append({"members": member_names, "active": active}) + return groups + + def _save_pre_tab_groups_to_settings(self, groups: List[Dict[str, object]]) -> None: + try: + self.settings.setValue(_PRE_TAB_GROUPS_KEY, json.dumps(groups)) + except Exception: + pass + + def _load_pre_tab_groups_from_settings(self) -> List[Dict[str, object]]: + try: + raw = self.settings.value(_PRE_TAB_GROUPS_KEY, "", type=str) + if not raw: + return [] + data = json.loads(raw) + if isinstance(data, list): + return [g for g in data if isinstance(g, dict)] + except Exception: + pass + return [] + + def _restore_pre_tab_groups_fallback(self, groups: List[Dict[str, object]]) -> None: + if not groups: + return + by_name = { + str(d.objectName() or ""): d for d in self.getPreDockWidgets() if str(d.objectName() or "") + } + for group in groups: + members_raw = group.get("members", []) + if not isinstance(members_raw, list): + continue + members = [by_name.get(str(n)) for n in members_raw] + members = [d for d in members if isinstance(d, QtWidgets.QDockWidget)] + members = [d for d in members if not d.isFloating()] + if len(members) < 2: + continue + root = members[0] + for d in members[1:]: + try: + self.tabifyDockWidget(root, d) + except Exception: + continue + active_name = str(group.get("active", "")) + active = by_name.get(active_name) + if isinstance(active, QtWidgets.QDockWidget) and active in members: + try: + active.show() + active.raise_() + active.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + self._pre_last_interacted_dock_name = active_name + except Exception: + pass + + def _schedule_pre_snapshot_retry(self, delay_ms: int) -> None: + if self._pre_snapshot_retry_scheduled: + return + self._pre_snapshot_retry_scheduled = True + QtCore.QTimer.singleShot(max(0, int(delay_ms)), self._retry_pre_snapshot_restore) + + def _retry_pre_snapshot_restore(self) -> None: + self._pre_snapshot_retry_scheduled = False + self._apply_pre_main_dock_snapshot_if_needed() + + def onPostDocksReady(self) -> None: + self.on_post_docks_ready() + + def on_post_docks_ready(self) -> None: + self._post_docks_ready = True + if self._force_fixed_dock_layouts: + self._pre_snapshot_applied = True + return + if not self._pre_snapshot_applied: + self._schedule_pre_snapshot_retry(0) + + def _set_data_panel_visible(self, visible: bool, persist: bool = True) -> None: + vis = bool(visible) + self.file_panel.setVisible(vis) + self.btn_toggle_data.blockSignals(True) + self.btn_toggle_data.setChecked(vis) + self.btn_toggle_data.blockSignals(False) + self._save_splitter_sizes() + if persist: + self._save_settings() + + def _toggle_data_panel_shortcut(self) -> None: + self._set_data_panel_visible(not self.file_panel.isVisible()) + + def _toggle_all_parameter_popups_shortcut(self) -> None: + if self._use_pg_dockarea_pre_layout: + any_open = any( + bool(dock.isVisible()) + for key, dock in self._pre_dockarea_docks.items() + if key in self._section_buttons + ) + if any_open: + for key in self._section_buttons.keys(): + self._set_pre_dockarea_visible(key, False) + self._set_section_button_checked(key, False) + self._last_opened_section = None + self._save_panel_layout_state() + return + self._toggle_section_shortcut("output") + return + any_open = any(d.isVisible() for d in self._section_docks.values()) + if any_open: + for key, dock in self._section_docks.items(): + dock.hide() + self._set_section_button_checked(key, False) + self._last_opened_section = None + return + self._toggle_section_shortcut("output") + + def _toggle_section_shortcut(self, key: str) -> None: + btn = self._section_buttons.get(key) + if btn is None: + return + next_state = not btn.isChecked() + self._set_section_button_checked(key, next_state) + self._toggle_section_popup(key, next_state) + + def _close_focused_popup(self) -> None: + if self._use_pg_dockarea_pre_layout: + if self._last_opened_section: + dock = self._pre_dockarea_dock(self._last_opened_section) + if dock is not None and dock.isVisible(): + dock.hide() + self._set_section_button_checked(self._last_opened_section, False) + self._last_opened_section = None + self._save_panel_layout_state() + return + fw = QtWidgets.QApplication.focusWidget() + while fw is not None and not isinstance(fw, QtWidgets.QDockWidget): + fw = fw.parentWidget() + if isinstance(fw, QtWidgets.QDockWidget): + fw.close() + return + if self._last_opened_section: + dock = self._section_docks.get(self._last_opened_section) + if dock is not None and dock.isVisible(): + dock.close() + + def _is_text_entry_focused(self) -> bool: + fw = QtWidgets.QApplication.focusWidget() + if fw is None: + return False + if isinstance(fw, QtWidgets.QAbstractButton): + return False + if isinstance(fw, (QtWidgets.QLineEdit, QtWidgets.QPlainTextEdit, QtWidgets.QTextEdit)): + return True + if isinstance(fw, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox, QtWidgets.QAbstractSpinBox)): + return True + if isinstance(fw, QtWidgets.QComboBox) and fw.isEditable(): + return True + parent = fw.parentWidget() + while parent is not None: + if isinstance(parent, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox, QtWidgets.QAbstractSpinBox)): + return True + if isinstance(parent, QtWidgets.QComboBox) and parent.isEditable(): + return True + parent = parent.parentWidget() + return False + + def _focus_first_editable(self, root: Optional[QtWidgets.QWidget]) -> None: + if root is None: + return + editable_types = ( + QtWidgets.QLineEdit, + QtWidgets.QPlainTextEdit, + QtWidgets.QTextEdit, + QtWidgets.QSpinBox, + QtWidgets.QDoubleSpinBox, + QtWidgets.QAbstractSpinBox, + QtWidgets.QComboBox, + ) + for w in root.findChildren(QtWidgets.QWidget): + if not isinstance(w, editable_types): + continue + if not w.isVisible() or not w.isEnabled(): + continue + if isinstance(w, QtWidgets.QComboBox) and not w.isEditable(): + continue + try: + w.setFocus(QtCore.Qt.FocusReason.TabFocusReason) + except Exception: + continue + if isinstance(w, QtWidgets.QAbstractSpinBox): + le = w.lineEdit() + if le is not None: + le.selectAll() + elif isinstance(w, QtWidgets.QLineEdit): + w.selectAll() + return + + def _bind_shortcut( + self, + sequence: str, + callback: Callable[[], None], + *, + require_non_text_focus: bool = False, + ) -> None: + shortcut = QtGui.QShortcut(QtGui.QKeySequence(sequence), self) + shortcut.setContext(QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut) + + def _on_activated() -> None: + if self.tabs.currentWidget() is not self.pre_tab: + return + if self._is_text_entry_focused(): + return + callback() + + shortcut.activated.connect(_on_activated) + self._shortcuts.append(shortcut) + + def _init_shortcuts(self) -> None: + # Keyboard-first workflow for preprocessing actions. + self._bind_shortcut("Ctrl+O", self._open_files_dialog) + self._bind_shortcut("Ctrl+Shift+O", self._open_folder_dialog) + self._bind_shortcut("Delete", self.file_panel._remove_selected_files, require_non_text_focus=True) + self._bind_shortcut("Ctrl+Return", self._trigger_preview, require_non_text_focus=True) + self._bind_shortcut("Ctrl+Enter", self._trigger_preview, require_non_text_focus=True) + self._bind_shortcut("Ctrl+E", self._export_selected_or_all, require_non_text_focus=True) + self._bind_shortcut("Ctrl+K", lambda: self._toggle_section_shortcut("artifacts"), require_non_text_focus=True) + self._bind_shortcut("Ctrl+F", lambda: self._toggle_section_shortcut("filtering"), require_non_text_focus=True) + self._bind_shortcut("Ctrl+B", lambda: self._toggle_section_shortcut("baseline"), require_non_text_focus=True) + self._bind_shortcut("Ctrl+M", lambda: self._toggle_section_shortcut("output"), require_non_text_focus=True) + self._bind_shortcut("Ctrl+Q", self._run_qc_dialog, require_non_text_focus=True) + self._bind_shortcut("Ctrl+Shift+Q", self._run_batch_qc, require_non_text_focus=True) + self._bind_shortcut("Ctrl+L", self.param_panel._load_config, require_non_text_focus=True) + self._bind_shortcut("Ctrl+S", self.param_panel._save_config, require_non_text_focus=True) + self._bind_shortcut("Ctrl+D", self._toggle_data_panel_shortcut, require_non_text_focus=True) + self._bind_shortcut("Ctrl+P", self._toggle_all_parameter_popups_shortcut, require_non_text_focus=True) + self._bind_shortcut("A", self._assign_pending_box_to_artifact, require_non_text_focus=True) + self._bind_shortcut("C", self._assign_pending_box_to_cut, require_non_text_focus=True) + self._bind_shortcut("S", self._assign_pending_box_to_section, require_non_text_focus=True) + self._bind_shortcut("Escape", self._close_focused_popup, require_non_text_focus=True) + + def _dock_area_from_settings( + self, + value: object, + default: QtCore.Qt.DockWidgetArea = QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + ) -> QtCore.Qt.DockWidgetArea: + left_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, 1) + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + top_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.TopDockWidgetArea, 4) + bottom_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, 8) + area_int = _dock_area_to_int(value, _dock_area_to_int(default, right_i)) + area_map: Dict[int, QtCore.Qt.DockWidgetArea] = { + left_i: QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, + right_i: QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + top_i: QtCore.Qt.DockWidgetArea.TopDockWidgetArea, + bottom_i: QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, + } + if area_int in area_map: + return area_map[area_int] + return default + + def _to_qbytearray(self, value: object) -> Optional[QtCore.QByteArray]: + if isinstance(value, QtCore.QByteArray): + return value + if isinstance(value, (bytes, bytearray)): + return QtCore.QByteArray(bytes(value)) + if isinstance(value, str): + # QSettings may return serialized bytearrays as text with some backends. + try: + return QtCore.QByteArray.fromBase64(value.encode("utf-8")) + except Exception: + return None + return None + + def _qbytearray_to_b64(self, value: Optional[QtCore.QByteArray]) -> str: + if value is None: + return "" + try: + if value.isEmpty(): + return "" + return bytes(value.toBase64()).decode("ascii") + except Exception: + return "" + + def _b64_to_qbytearray(self, value: object) -> Optional[QtCore.QByteArray]: + if not value: + return None + if isinstance(value, QtCore.QByteArray): + return value + if isinstance(value, (bytes, bytearray)): + try: + return QtCore.QByteArray.fromBase64(bytes(value)) + except Exception: + return None + if isinstance(value, str): + try: + return QtCore.QByteArray.fromBase64(value.encode("ascii")) + except Exception: + return None + return None + + def _dock_state_prefix_presence(self, state: QtCore.QByteArray) -> Tuple[bool, bool]: + """ + Return (has_pre_prefix, has_post_prefix) for a Qt dock-state blob. + Object names may be serialized as ASCII or UTF-16LE. + """ + try: + raw = bytes(state) + except Exception: + return False, False + pre_ascii = b"pre." + post_ascii = b"post." + pre_utf16 = "pre.".encode("utf-16-le") + post_utf16 = "post.".encode("utf-16-le") + has_pre = (pre_ascii in raw) or (pre_utf16 in raw) + has_post = (post_ascii in raw) or (post_utf16 in raw) + return has_pre, has_post + + def _is_tab_scoped_dock_state(self, tab_name: str, state: QtCore.QByteArray) -> bool: + if tab_name not in {"pre", "post"}: + return False + if state is None or state.isEmpty(): + return False + has_pre, has_post = self._dock_state_prefix_presence(state) + if tab_name == "pre" and has_post: + return False + if tab_name == "post" and has_pre: + return False + return True + + def _migrate_legacy_dock_state_settings(self) -> None: + """ + Drop legacy full-window dock blobs that reference old object names. + New snapshots are tab-scoped and use pre./post. dock prefixes. + """ + try: + if self.settings.contains("pre_main_dock_state_v3") and not self.settings.contains(_PRE_DOCK_STATE_KEY): + self.settings.remove("pre_main_dock_state_v3") + if self.settings.contains("post_main_dock_state_v3") and not self.settings.contains(_POST_DOCK_STATE_KEY): + self.settings.remove("post_main_dock_state_v3") + pre_state = self._to_qbytearray(self.settings.value(_PRE_DOCK_STATE_KEY, None)) + if pre_state is not None and not pre_state.isEmpty(): + if not self._is_tab_scoped_dock_state("pre", pre_state): + self.settings.remove(_PRE_DOCK_STATE_KEY) + post_state = self._to_qbytearray(self.settings.value(_POST_DOCK_STATE_KEY, None)) + if post_state is not None and not post_state.isEmpty(): + if not self._is_tab_scoped_dock_state("post", post_state): + self.settings.remove(_POST_DOCK_STATE_KEY) + except Exception: + pass + + def _panel_config_json_path(self) -> str: + base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + return os.path.join(base_dir, "panel_layout.json") + + def _load_panel_config_json_into_settings(self) -> None: + """Load panel layout JSON into QSettings so existing restore logic can use it.""" + path = self._panel_config_json_path() + if not os.path.isfile(path): + return + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + return + self._load_panel_config_payload_into_settings(data) + + def _load_panel_config_payload_into_settings(self, data: object) -> None: + """Load a panel layout payload (same schema as panel_layout.json) into QSettings.""" + if not isinstance(data, dict): + return + + try: + layout_version = int(data.get("version", 1)) + except Exception: + layout_version = 1 + # Snapshot blobs are considered stable starting from layout schema v3. + # v2 files may contain mixed pre/post docks from legacy full-window capture. + allow_snapshot_blobs = layout_version >= 3 + + pre = data.get("pre", {}) if isinstance(data.get("pre"), dict) else {} + post = data.get("post", {}) if isinstance(data.get("post"), dict) else {} + + try: + if "pre_data_panel_visible" in pre: + self.settings.setValue("pre_data_panel_visible", bool(pre["pre_data_panel_visible"])) + if "pre_splitter_sizes" in pre and isinstance(pre["pre_splitter_sizes"], list): + self.settings.setValue("pre_splitter_sizes", [int(x) for x in pre["pre_splitter_sizes"]]) + if allow_snapshot_blobs and "pre_main_dock_state" in pre: + ba = self._b64_to_qbytearray(pre.get("pre_main_dock_state")) + if ba is not None and self._is_tab_scoped_dock_state("pre", ba): + self.settings.setValue(_PRE_DOCK_STATE_KEY, ba) + else: + self.settings.remove(_PRE_DOCK_STATE_KEY) + if allow_snapshot_blobs and "post_main_dock_state" in post: + ba = self._b64_to_qbytearray(post.get("post_main_dock_state")) + if ba is not None and self._is_tab_scoped_dock_state("post", ba): + self.settings.setValue(_POST_DOCK_STATE_KEY, ba) + else: + self.settings.remove(_POST_DOCK_STATE_KEY) + if "tab_groups" in pre and isinstance(pre.get("tab_groups"), list): + self.settings.setValue(_PRE_TAB_GROUPS_KEY, json.dumps(pre.get("tab_groups"))) + except Exception: + pass + + def _apply_section_settings(prefix: str, section_map: object) -> None: + if not isinstance(section_map, dict): + return + for key, sec in section_map.items(): + if not isinstance(sec, dict): + continue + base = f"{prefix}/{key}" + if "visible" in sec: + self.settings.setValue(f"{base}/visible", bool(sec["visible"])) + if "floating" in sec: + self.settings.setValue(f"{base}/floating", bool(sec["floating"])) + if "area" in sec: + try: + self.settings.setValue(f"{base}/area", int(sec["area"])) + except Exception: + pass + if "geometry" in sec: + ba = self._b64_to_qbytearray(sec.get("geometry")) + if ba is not None: + self.settings.setValue(f"{base}/geometry", ba) + + _apply_section_settings("pre_section_docks", pre.get("sections")) + _apply_section_settings("post_section_docks", post.get("sections")) + + art = pre.get("artifact", {}) if isinstance(pre.get("artifact"), dict) else {} + if art: + base = "pre_artifact_dock_state" + if "visible" in art: + self.settings.setValue(f"{base}/visible", bool(art["visible"])) + if "floating" in art: + self.settings.setValue(f"{base}/floating", bool(art["floating"])) + if "area" in art: + try: + self.settings.setValue(f"{base}/area", int(art["area"])) + except Exception: + pass + if "geometry" in art: + ba = self._b64_to_qbytearray(art.get("geometry")) + if ba is not None: + self.settings.setValue(f"{base}/geometry", ba) + + try: + self.settings.sync() + except Exception: + pass + + def _collect_panel_layout_payload(self) -> Dict[str, object]: + """Build the panel layout payload used for both JSON persistence and config export.""" + # Ensure QSettings has the latest dock values + try: + self._save_panel_layout_state() + except Exception: + pass + try: + self.post_tab.flush_post_section_state_to_settings() + self.post_tab._save_panel_layout_state() + except Exception: + pass + + splitter_sizes: Optional[List[int]] = None + try: + raw_sizes = self.settings.value("pre_splitter_sizes", None) + if raw_sizes is not None and hasattr(raw_sizes, "__len__"): + splitter_sizes = [int(x) for x in raw_sizes] + except Exception: + splitter_sizes = None + + def _read_section_settings(prefix: str, keys: List[str]) -> Dict[str, Dict[str, object]]: + out: Dict[str, Dict[str, object]] = {} + for key in keys: + base = f"{prefix}/{key}" + visible = self.settings.value(f"{base}/visible", None) + floating = self.settings.value(f"{base}/floating", None) + area = self.settings.value(f"{base}/area", None) + geom = self._to_qbytearray(self.settings.value(f"{base}/geometry", None)) + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + out[key] = { + "visible": _to_bool(visible, False) if visible is not None else False, + "floating": _to_bool(floating, True) if floating is not None else True, + "area": _dock_area_to_int(area, right_i) if area is not None else right_i, + "geometry": self._qbytearray_to_b64(geom), + } + return out + + if self._use_pg_dockarea_pre_layout and self._pre_dockarea_docks: + pre_sections = [k for k in self._pre_dockarea_docks.keys() if k != "artifacts_list"] + else: + pre_sections = list(self._section_docks.keys()) + post_sections = [] + try: + self.post_tab.ensure_section_popups_initialized() + post_sections = list(self.post_tab.get_section_popup_keys()) + except Exception: + post_sections = [] + + pre_main = self._to_qbytearray(self.settings.value(_PRE_DOCK_STATE_KEY, None)) + post_main = self._to_qbytearray(self.settings.value(_POST_DOCK_STATE_KEY, None)) + pre_tab_groups = self._load_pre_tab_groups_from_settings() + + art_geom = self._to_qbytearray(self.settings.value("pre_artifact_dock_state/geometry", None)) + art_visible = self.settings.value("pre_artifact_dock_state/visible", None) + art_floating = self.settings.value("pre_artifact_dock_state/floating", None) + art_area = self.settings.value("pre_artifact_dock_state/area", None) + + data = { + "version": 3, + "pre": { + "pre_data_panel_visible": _to_bool(self.settings.value("pre_data_panel_visible", True), True), + "pre_splitter_sizes": splitter_sizes, + "pre_main_dock_state": self._qbytearray_to_b64(pre_main), + "tab_groups": pre_tab_groups, + "sections": _read_section_settings("pre_section_docks", pre_sections), + "artifact": { + "visible": _to_bool(art_visible, False) if art_visible is not None else False, + "floating": _to_bool(art_floating, False) if art_floating is not None else False, + "area": int(art_area) if art_area is not None else _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2), + "geometry": self._qbytearray_to_b64(art_geom), + }, + }, + "post": { + "post_main_dock_state": self._qbytearray_to_b64(post_main), + "sections": _read_section_settings("post_section_docks", post_sections), + }, + } + return data + + def _save_panel_config_json(self) -> None: + """Persist current panel layout into a JSON file.""" + try: + data = self._collect_panel_layout_payload() + path = self._panel_config_json_path() + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except Exception: + _LOG.exception("Failed to write panel layout JSON") + + def _export_preprocessing_ui_state_for_config(self) -> Dict[str, object]: + """Extra UI payload stored in preprocessing_config.json.""" + return { + "artifact_overlay_visible": bool(self.param_panel.artifact_overlay_visible()), + "artifact_thresholds_visible": bool(self.plots.artifact_thresholds_visible()), + "export_selection": self.param_panel.export_selection().to_dict(), + "export_channel_names": self.param_panel.export_channel_names(), + "export_trigger_names": self.param_panel.export_trigger_names(), + "panel_layout": self._collect_panel_layout_payload(), + } + + def _apply_panel_layout_from_settings(self) -> None: + """ + Apply dock disposition from current QSettings values. + Used after importing panel layout from preprocessing config files. + """ + if self._force_fixed_dock_layouts: + self._apply_pre_fixed_layout() + try: + self.post_tab.ensure_section_popups_initialized() + if hasattr(self.post_tab, "apply_fixed_default_layout"): + self.post_tab.apply_fixed_default_layout() + except Exception: + pass + if self.tabs.currentWidget() is self.pre_tab: + self._enforce_only_tab_docks_visible("pre") + else: + self._enforce_only_tab_docks_visible("post") + return + + self._restore_panel_layout_state() + self._pre_snapshot_applied = False + self._pre_snapshot_retry_attempts = 0 + self._pre_snapshot_retry_scheduled = False + self._apply_pre_main_dock_snapshot_if_needed() + try: + self.post_tab.ensure_section_popups_initialized() + self.post_tab._restore_panel_layout_state() + self.post_tab._post_snapshot_applied = False + self.post_tab._apply_post_main_dock_snapshot_if_needed() + except Exception: + pass + if self.tabs.currentWidget() is self.pre_tab: + self._enforce_only_tab_docks_visible("pre") + else: + self._enforce_only_tab_docks_visible("post") + + def _import_preprocessing_ui_state_from_config(self, ui_state: Dict[str, object]) -> None: + if not isinstance(ui_state, dict): + return + if "artifact_overlay_visible" in ui_state: + visible = bool(ui_state.get("artifact_overlay_visible")) + self.param_panel.set_artifact_overlay_visible(visible) + self.plots.set_artifact_overlay_visible(visible) + if "artifact_thresholds_visible" in ui_state: + self.plots.set_artifact_thresholds_visible(bool(ui_state.get("artifact_thresholds_visible"))) + if "export_selection" in ui_state: + self.param_panel.set_export_selection(ExportSelection.from_dict(ui_state.get("export_selection"))) + if "export_channel_names" in ui_state: + self.param_panel.set_export_channel_names(list(ui_state.get("export_channel_names") or [])) + if "export_trigger_names" in ui_state: + self.param_panel.set_export_trigger_names(list(ui_state.get("export_trigger_names") or [])) + self._update_export_summary_label() + panel_layout = ui_state.get("panel_layout") + if isinstance(panel_layout, dict): + self._load_panel_config_payload_into_settings(panel_layout) + self._apply_panel_layout_from_settings() + self._save_panel_config_json() + self._save_settings() + + def _sync_section_button_states_from_docks(self) -> None: + if self._use_pg_dockarea_pre_layout: + self._last_opened_section = None + for key in self._section_buttons.keys(): + dock = self._pre_dockarea_dock(key) + visible = bool(dock.isVisible()) if dock is not None else False + self._set_section_button_checked(key, visible) + if visible and self._last_opened_section is None: + self._last_opened_section = key + return + self._last_opened_section = None + for key, dock in self._section_docks.items(): + vis = bool(dock.isVisible()) + self._set_section_button_checked(key, vis) + if vis: + self._last_opened_section = key + + def _save_panel_layout_state(self) -> None: + """Persist popup/artifact panel visibility, docking mode, area, and geometry.""" + if not self._panel_layout_persistence_ready: + return + if self._is_restoring_panel_layout: + return + if self._suspend_panel_layout_persistence: + return + # Do not overwrite stored layout while preprocessing panels are hidden for tab switching. + if self._pre_popups_hidden_by_tab_switch: + return + + if self._use_pg_dockarea_pre_layout: + self._save_pre_dockarea_layout_state() + try: + self.settings.sync() + except Exception: + pass + return + + # Per-dock persistence is isolated so one faulty dock payload cannot drop all others. + for key, dock in self._section_docks.items(): + try: + base = f"pre_section_docks/{key}" + cached = ( + self._pre_section_state_before_tab_switch.get(key, {}) + if self._pre_popups_hidden_by_tab_switch + else {} + ) + visible = bool(cached.get("visible", dock.isVisible())) + floating = bool(cached.get("floating", dock.isFloating())) + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + area_val = _dock_area_to_int(cached.get("area", self.dockWidgetArea(dock)), right_i) + geom = cached.get("geometry", dock.saveGeometry()) + self.settings.setValue(f"{base}/visible", visible) + self.settings.setValue(f"{base}/floating", floating) + self.settings.setValue(f"{base}/area", area_val) + self.settings.setValue(f"{base}/geometry", geom) + except Exception: + continue + + if isinstance(self.art_dock, QtWidgets.QDockWidget): + try: + base = "pre_artifact_dock_state" + cached = self._pre_artifact_state_before_tab_switch if self._pre_popups_hidden_by_tab_switch else {} + visible = bool(cached.get("visible", self.art_dock.isVisible())) + floating = bool(cached.get("floating", self.art_dock.isFloating())) + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + area_val = _dock_area_to_int(cached.get("area", self.dockWidgetArea(self.art_dock)), right_i) + geom = cached.get("geometry", self.art_dock.saveGeometry()) + self.settings.setValue(f"{base}/visible", visible) + self.settings.setValue(f"{base}/floating", floating) + self.settings.setValue(f"{base}/area", area_val) + self.settings.setValue(f"{base}/geometry", geom) + except Exception: + pass + try: + self._save_pre_tab_groups_to_settings(self._capture_pre_tab_groups_state()) + except Exception: + pass + try: + self.settings.sync() + except Exception: + pass + + def _save_full_main_dock_state(self) -> None: + """ + Save full main-window dock disposition (tabified/split relationships). + This complements per-dock visibility settings. + """ + try: + self.settings.setValue("main_dock_state_v2", self.saveState(_DOCK_STATE_VERSION)) + self.settings.sync() + except Exception: + pass + + def _restore_full_main_dock_state(self) -> None: + """ + Restore full main-window dock disposition after all docks are registered. + """ + try: + raw = self.settings.value("main_dock_state_v2", None) + state = self._to_qbytearray(raw) + if state is None or state.isEmpty(): + return + ok = self.restoreState(state, _DOCK_STATE_VERSION) + if not ok: + # Drop invalid payload and fall back to per-dock restore. + self.settings.remove("main_dock_state_v2") + return + try: + self.post_tab.mark_dock_layout_restored() + except Exception: + pass + except Exception: + pass + + def _restore_panel_layout_state(self) -> None: + """Restore popup/artifact panel layout from the previous app session.""" + if self._use_pg_dockarea_pre_layout: + self._is_restoring_panel_layout = True + try: + self._setup_section_popups() + self._restore_pre_dockarea_layout_state() + finally: + self._is_restoring_panel_layout = False + return + + self._is_restoring_panel_layout = True + for key, dock in self._section_docks.items(): + base = f"pre_section_docks/{key}" + try: + visible = _to_bool(self.settings.value(f"{base}/visible", False), False) + floating = _to_bool(self.settings.value(f"{base}/floating", True), True) + area_val = self.settings.value( + f"{base}/area", + _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2), + ) + area = self._dock_area_from_settings(area_val, QtCore.Qt.DockWidgetArea.RightDockWidgetArea) + geom = self._to_qbytearray(self.settings.value(f"{base}/geometry", None)) + + dock.blockSignals(True) + if bool(floating): + dock.setFloating(True) + else: + self.addDockWidget(area, dock) + dock.setFloating(False) + + if geom is not None and not geom.isEmpty(): + dock.restoreGeometry(geom) + self._section_popup_initialized.add(key) + + if visible: + dock.show() + if dock.isFloating() and not self._is_popup_on_screen(dock): + self._position_section_popup(dock) + self._set_section_button_checked(key, True) + self._last_opened_section = key + else: + dock.hide() + self._set_section_button_checked(key, False) + except Exception: + continue + finally: + try: + dock.blockSignals(False) + except Exception: + pass + + if isinstance(self.art_dock, QtWidgets.QDockWidget): + try: + base = "pre_artifact_dock_state" + visible = _to_bool(self.settings.value(f"{base}/visible", False), False) + floating = _to_bool(self.settings.value(f"{base}/floating", False), False) + area_val = self.settings.value( + f"{base}/area", + _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2), + ) + area = self._dock_area_from_settings(area_val, QtCore.Qt.DockWidgetArea.RightDockWidgetArea) + geom = self._to_qbytearray(self.settings.value(f"{base}/geometry", None)) + + if bool(floating): + self.art_dock.setFloating(True) + else: + self.addDockWidget(area, self.art_dock) + self.art_dock.setFloating(False) + + if geom is not None and not geom.isEmpty(): + self.art_dock.restoreGeometry(geom) + self.art_dock.setVisible(bool(visible)) + except Exception: + pass + + self._sync_section_button_states_from_docks() + self._restore_pre_tab_groups_fallback(self._load_pre_tab_groups_from_settings()) + self._is_restoring_panel_layout = False + + def _has_saved_pre_layout_state(self) -> bool: + try: + if self._use_pg_dockarea_pre_layout: + if self.settings.contains(_PRE_DOCKAREA_STATE_KEY) or self.settings.contains(_PRE_DOCKAREA_VISIBLE_KEY): + return True + if self.settings.contains(_PRE_DOCK_STATE_KEY): + return True + if self.settings.contains("pre_artifact_dock_state/visible"): + return True + keys = list(self._section_docks.keys()) + if self._use_pg_dockarea_pre_layout and self._pre_dockarea_docks: + keys = [k for k in self._pre_dockarea_docks.keys() if k != "artifacts_list"] + for key in keys: + if self.settings.contains(f"pre_section_docks/{key}/visible"): + return True + except Exception: + pass + return False + + def _has_saved_post_layout_state(self) -> bool: + try: + if self.settings.contains(_POST_DOCK_STATE_KEY): + return True + self.post_tab.ensure_section_popups_initialized() + for key in self.post_tab.get_section_popup_keys(): + if self.settings.contains(f"post_section_docks/{key}/visible"): + return True + except Exception: + pass + return False + + # ---------------- Settings persistence ---------------- + + def _restore_settings(self) -> None: + last_dir = self.settings.value("last_open_dir", "", type=str) + if last_dir and os.path.isdir(last_dir): + self.file_panel.set_path_hint(last_dir) + + try: + app_theme = self.settings.value("app_theme_mode", "dark", type=str) + except Exception: + app_theme = "dark" + self._apply_app_theme(app_theme, persist=False) + + # restore params + try: + raw = self.settings.value("params_json", "", type=str) + if raw: + d = json.loads(raw) + # One-time migration: ensure invert polarity defaults to off + migrated = self.settings.value("invert_polarity_migrated", False, type=bool) + if not migrated: + d["invert_polarity"] = False + self.settings.setValue("invert_polarity_migrated", True) + self.settings.setValue("params_json", json.dumps(d)) + p = ProcessingParams.from_dict(d) + self.param_panel.set_params(p) + self._update_plot_status(fs_target=float(p.target_fs_hz)) + except Exception: + pass + + try: + show_overlay = self.settings.value("artifact_overlay_visible", True, type=bool) + self.param_panel.set_artifact_overlay_visible(bool(show_overlay)) + self.plots.set_artifact_overlay_visible(bool(show_overlay)) + except Exception: + pass + try: + show_thresholds = self.settings.value("artifact_thresholds_visible", True, type=bool) + self.plots.set_artifact_thresholds_visible(bool(show_thresholds)) + except Exception: + pass + try: + default_bg = "white" if self._app_theme_mode == "light" else "dark" + plot_bg = self.settings.value("pre_plot_background", default_bg, type=str) + except Exception: + plot_bg = "dark" + try: + plot_grid = _to_bool(self.settings.value("pre_plot_grid", True), True) + except Exception: + plot_grid = True + self._apply_pre_plot_style(plot_bg, plot_grid, persist=False) + + if self._force_fixed_dock_layouts: + # Fixed mode: always enforce deterministic defaults. + try: + self.pre_splitter.setSizes([300, 1200]) + except Exception: + pass + try: + splitter_sizes = self.settings.value("pre_splitter_sizes", None) + if splitter_sizes is None: + splitter_sizes = self.settings.value("splitter_sizes", None) + if splitter_sizes and hasattr(splitter_sizes, "__len__"): + vals = [int(x) for x in splitter_sizes] + if self._use_pg_dockarea_pre_layout: + if len(vals) >= 3: + self.pre_splitter.setSizes(vals[:3]) + elif len(vals) == 2: + self.pre_splitter.setSizes([vals[0], vals[1], 520]) + elif len(vals) >= 3: + left = max(260, vals[0]) + center = max(640, vals[1] + vals[2]) + self.pre_splitter.setSizes([left, center]) + elif len(vals) == 2: + self.pre_splitter.setSizes(vals[:2]) + except Exception: + pass + try: + show_data = self.settings.value("pre_data_panel_visible", False, type=bool) + except Exception: + show_data = False + self._set_data_panel_visible(bool(show_data), persist=False) + + self._apply_pre_fixed_layout() + + try: + self.post_tab.ensure_section_popups_initialized() + if hasattr(self.post_tab, "apply_fixed_default_layout"): + self.post_tab.apply_fixed_default_layout() + # Keep post docks detached while Preprocessing is active at startup. + self._hide_dock_widgets(self.getPostDockWidgets(), remove=True) + except Exception: + pass + else: + # restore splitter sizes (2-pane layout; migrate older layouts) + try: + splitter_sizes = self.settings.value("pre_splitter_sizes", None) + if splitter_sizes is None: + splitter_sizes = self.settings.value("splitter_sizes", None) + if splitter_sizes and hasattr(splitter_sizes, "__len__"): + vals = [int(x) for x in splitter_sizes] + if self._use_pg_dockarea_pre_layout: + if len(vals) >= 3: + self.pre_splitter.setSizes(vals[:3]) + elif len(vals) == 2: + self.pre_splitter.setSizes([vals[0], vals[1], 520]) + elif len(vals) >= 3: + # Migrate old 3-pane [left, center, right] into [left, center+right]. + left = max(260, vals[0]) + center = max(640, vals[1] + vals[2]) + self.pre_splitter.setSizes([left, center]) + elif len(vals) == 2: + self.pre_splitter.setSizes(vals[:2]) + except Exception: + pass + + # restore data panel visibility + try: + show_data = self.settings.value("pre_data_panel_visible", True, type=bool) + self._set_data_panel_visible(bool(show_data), persist=False) + except Exception: + pass + + # restore panel layout/disposition (floating popups + artifacts dock). + self._restore_panel_layout_state() + # Apply default preprocessing dock layout if no saved layout exists. + self._apply_pre_default_layout_if_missing() + # Apply saved preprocessing dock snapshot at startup. + self._apply_pre_main_dock_snapshot_if_needed() + + # restore last selected main tab + try: + idx = self.settings.value("main_current_tab", 0, type=int) + if isinstance(idx, int) and 0 <= idx < self.tabs.count(): + self.tabs.setCurrentIndex(idx) + except Exception: + pass + + def _save_settings(self) -> None: + try: + last_dir = self.file_panel.current_dir_hint() + if last_dir: + self.settings.setValue("last_open_dir", last_dir) + except Exception: + pass + + try: + p = self.param_panel.get_params() + self.settings.setValue("params_json", json.dumps(p.to_dict())) + except Exception: + pass + + try: + self.settings.setValue("artifact_overlay_visible", bool(self.param_panel.artifact_overlay_visible())) + except Exception: + pass + try: + self.settings.setValue("artifact_thresholds_visible", bool(self.plots.artifact_thresholds_visible())) + except Exception: + pass + try: + self.settings.setValue("pre_plot_background", str(self.plots.plot_background_mode())) + self.settings.setValue("pre_plot_grid", bool(self.plots.plot_grid_visible())) + except Exception: + pass + try: + self.settings.setValue("app_theme_mode", str(self._app_theme_mode)) + except Exception: + pass + + try: + self.settings.setValue("pre_data_panel_visible", bool(self.file_panel.isVisible())) + except Exception: + pass + try: + self.settings.setValue("main_current_tab", int(self.tabs.currentIndex())) + except Exception: + pass + try: + self.settings.sync() + except Exception: + pass + + def _save_splitter_sizes(self, *_args) -> None: + """Save the current splitter sizes to settings.""" + try: + if hasattr(self, "pre_splitter") and self.pre_splitter: + sizes = self.pre_splitter.sizes() + self.settings.setValue("pre_splitter_sizes", sizes) + self.settings.setValue("splitter_sizes", sizes) + except Exception: + pass + + # ---------------- File loading ---------------- + + def _load_recent_preprocessing_files(self) -> List[str]: + raw = self.settings.value("recent_pre_files", "[]", type=str) + try: + data = json.loads(raw) if raw else [] + except Exception: + data = [] + out: List[str] = [] + if isinstance(data, list): + for item in data: + p = str(item or "").strip() + if p: + out.append(p) + return out + + def _save_recent_preprocessing_files(self, paths: List[str]) -> None: + try: + self.settings.setValue("recent_pre_files", json.dumps(paths)) + except Exception: + pass + + def _push_recent_preprocessing_files(self, paths: List[str], max_items: int = 15) -> None: + if not paths: + return + existing = self._load_recent_preprocessing_files() + merged: List[str] = [] + for p in paths: + sp = str(p or "").strip() + if not sp: + continue + if sp in merged: + continue + merged.append(sp) + for p in existing: + if p not in merged: + merged.append(p) + self._save_recent_preprocessing_files(merged[:max_items]) + + def _refresh_recent_preprocessing_menu(self) -> None: + if not hasattr(self, "menu_workflow_load_recent"): + return + menu = self.menu_workflow_load_recent + menu.clear() + recent = self._load_recent_preprocessing_files() + if not recent: + act_empty = menu.addAction("(No recent files)") + act_empty.setEnabled(False) + return + + missing: List[str] = [] + for path in recent: + label = os.path.basename(path) or path + if not os.path.isfile(path): + label = f"{label} (missing)" + act = menu.addAction(label) + act.setToolTip(path) + act.setEnabled(os.path.isfile(path)) + if os.path.isfile(path): + act.triggered.connect(lambda _checked=False, p=path: self._add_files([p])) + else: + missing.append(path) + menu.addSeparator() + act_clear = menu.addAction("Clear recent") + act_clear.triggered.connect(lambda: self._save_recent_preprocessing_files([])) + if missing: + act_prune = menu.addAction("Remove missing") + act_prune.triggered.connect(self._prune_recent_preprocessing_files) + + def _prune_recent_preprocessing_files(self) -> None: + recent = self._load_recent_preprocessing_files() + kept = [p for p in recent if os.path.isfile(p)] + self._save_recent_preprocessing_files(kept) + + def _open_files_dialog(self) -> None: + start_dir = self.file_panel.current_dir_hint() or self.settings.value("last_open_dir", "", type=str) or os.getcwd() + paths, _ = QtWidgets.QFileDialog.getOpenFileNames( + self, + "Open files", + start_dir, + "Doric files (*.doric *.h5 *.hdf5);;All files (*.*)", + ) + if not paths: + return + + self.settings.setValue("last_open_dir", os.path.dirname(paths[0])) + self._push_recent_preprocessing_files(paths) + self._add_files(paths) + + def _open_folder_dialog(self) -> None: + start_dir = self.file_panel.current_dir_hint() or self.settings.value("last_open_dir", "", type=str) or os.getcwd() + folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Add folder with .doric", start_dir) + if not folder: + return + self.settings.setValue("last_open_dir", folder) + + paths: List[str] = [] + for fn in os.listdir(folder): + if fn.lower().endswith((".doric", ".h5", ".hdf5")): + paths.append(os.path.join(folder, fn)) + paths.sort() + self._push_recent_preprocessing_files(paths) + self._add_files(paths) + + def _add_files(self, paths: List[str]) -> None: + for p in paths: + if p in self._loaded_files: + continue + try: + doric = self.processor.load_file(p) + self._loaded_files[p] = doric + self.file_panel.add_file(p) + self._show_status_message(f"Loaded: {os.path.basename(p)}", 5000) + except Exception as e: + loaded_from_processed: Optional[LoadedDoricFile] = None + ext = os.path.splitext(p)[1].lower() + if ext in (".h5", ".hdf5"): + loaded_from_processed = self._load_processed_h5_as_pre_file(p) + if loaded_from_processed is not None: + self._loaded_files[p] = loaded_from_processed + self.file_panel.add_file(p) + self._show_status_message( + f"Loaded processed H5 as preprocessing source: {os.path.basename(p)}", + 6000, + ) + continue + QtWidgets.QMessageBox.critical(self, "Load error", f"Failed to load:\n{p}\n\n{e}") + + self._push_recent_preprocessing_files(paths) + + # set current selection -> triggers preview + self._on_file_selection_changed() + + # ---------------- Current selection ---------------- + + def _selected_paths(self) -> List[str]: + return self.file_panel.selected_paths() + + def _current_key(self) -> Optional[Tuple[str, str]]: + if not self._current_path or not self._current_channel: + return None + return (self._current_path, self._current_channel) + + def _focus_data_browser(self) -> None: + if not self.file_panel.isVisible(): + self._set_data_panel_visible(True) + self.file_panel.setFocus() + self.file_panel.list_files.setFocus() + + def _hide_preprocessing_popups_for_tab_switch(self) -> None: + if self._use_pg_dockarea_pre_layout: + # DockArea lives inside the Preprocessing tab widget; avoid costly hide/remove + # churn during main-tab switches for smoother transitions. + return + if self._pre_popups_hidden_by_tab_switch: + # Re-apply hide in case late dock events re-show a preprocessing dock. + self._enforce_preprocessing_popups_hidden() + return + host = self + self._pre_section_visibility_before_tab_switch = { + key: bool(dock.isVisible()) for key, dock in self._section_docks.items() + } + self._pre_section_state_before_tab_switch = {} + for key, dock in self._section_docks.items(): + area = _dock_area_to_int(host.dockWidgetArea(dock), _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2)) + self._pre_section_state_before_tab_switch[key] = { + "visible": bool(dock.isVisible()), + "floating": bool(dock.isFloating()), + "area": area, + "geometry": dock.saveGeometry(), + } + self._pre_artifact_visible_before_tab_switch = bool(self.art_dock.isVisible()) + self._pre_artifact_state_before_tab_switch = { + "visible": bool(self.art_dock.isVisible()), + "floating": bool(self.art_dock.isFloating()), + "area": _dock_area_to_int(host.dockWidgetArea(self.art_dock), _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2)), + "geometry": self.art_dock.saveGeometry(), + } + self._pre_advanced_visible_before_tab_switch = bool( + self._advanced_dialog is not None and self._advanced_dialog.isVisible() + ) + self._pre_tab_groups_before_tab_switch = self._capture_pre_tab_groups_state() + self._pre_main_dock_state_before_tab_switch = self.captureDockSnapshotForTab("pre") + self._store_pre_main_dock_snapshot() + # Mark switch-hide state before any dock visibility changes so asynchronous + # visibility signals cannot persist temporary hidden defaults. + self._pre_popups_hidden_by_tab_switch = True + # Persist cached state now; dock hide/remove operations below are temporary. + self._persist_hidden_preprocessing_layout_state() + + self._suspend_panel_layout_persistence = True + try: + for key in self._section_docks.keys(): + self._set_section_button_checked(key, False) + self._hide_dock_widgets(self.getPreDockWidgets(), remove=True) + if self._advanced_dialog is not None: + self._advanced_dialog.hide() + finally: + self._suspend_panel_layout_persistence = False + + def _enforce_preprocessing_popups_hidden(self) -> None: + """ + Hard-hide preprocessing docks/dialogs while Post Processing is active. + This protects against late Qt dock re-show events when dock tab stacks are rebuilt. + """ + if self._use_pg_dockarea_pre_layout: + if isinstance(self.art_dock, QtWidgets.QDockWidget): + try: + self.art_dock.hide() + self.removeDockWidget(self.art_dock) + except Exception: + pass + return + if hasattr(self, "tabs") and self.tabs.currentWidget() is self.pre_tab: + return + self._suspend_panel_layout_persistence = True + try: + for key in self._section_docks.keys(): + self._set_section_button_checked(key, False) + self._hide_dock_widgets(self.getPreDockWidgets(), remove=True) + if self._advanced_dialog is not None: + self._advanced_dialog.hide() + # Extra safety: hide any dock that belongs to preprocessing by object name prefix. + for dock in self.findChildren(QtWidgets.QDockWidget): + name = str(dock.objectName() or "") + if name.startswith(_PRE_DOCK_PREFIX): + dock.hide() + try: + self.removeDockWidget(dock) + except Exception: + pass + finally: + self._suspend_panel_layout_persistence = False + + def _enforce_postprocessing_popups_hidden(self) -> None: + """Hide post-processing docks while Preprocessing is active.""" + if hasattr(self, "tabs") and self.tabs.currentWidget() is not self.pre_tab: + return + remove_post = not self._force_fixed_dock_layouts + self._hide_dock_widgets(self.getPostDockWidgets(), remove=remove_post) + + def _store_pre_main_dock_snapshot(self) -> None: + """Persist the current preprocessing dock arrangement.""" + if self._use_pg_dockarea_pre_layout: + self._save_panel_layout_state() + return + try: + state = self.captureDockSnapshotForTab("pre") + if state is not None and not state.isEmpty(): + self.settings.setValue(_PRE_DOCK_STATE_KEY, state) + self._save_pre_tab_groups_to_settings(self._capture_pre_tab_groups_state()) + self.settings.sync() + except Exception: + pass + + def _persist_hidden_preprocessing_layout_state(self) -> None: + """ + Persist cached preprocessing layout while preprocessing docks are hidden + during a main-tab switch. + """ + if self._use_pg_dockarea_pre_layout: + self._save_panel_layout_state() + return + if not self._pre_popups_hidden_by_tab_switch: + return + right_i = _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2) + try: + for key in self._section_docks.keys(): + state = self._pre_section_state_before_tab_switch.get(key, {}) + base = f"pre_section_docks/{key}" + self.settings.setValue(f"{base}/visible", bool(state.get("visible", False))) + self.settings.setValue(f"{base}/floating", bool(state.get("floating", True))) + self.settings.setValue(f"{base}/area", _dock_area_to_int(state.get("area", right_i), right_i)) + geom = state.get("geometry") + if isinstance(geom, QtCore.QByteArray) and not geom.isEmpty(): + self.settings.setValue(f"{base}/geometry", geom) + except Exception: + pass + + try: + art_state = self._pre_artifact_state_before_tab_switch or {} + base = "pre_artifact_dock_state" + self.settings.setValue(f"{base}/visible", bool(art_state.get("visible", False))) + self.settings.setValue(f"{base}/floating", bool(art_state.get("floating", False))) + self.settings.setValue(f"{base}/area", _dock_area_to_int(art_state.get("area", right_i), right_i)) + art_geom = art_state.get("geometry") + if isinstance(art_geom, QtCore.QByteArray) and not art_geom.isEmpty(): + self.settings.setValue(f"{base}/geometry", art_geom) + except Exception: + pass + + try: + state = self._pre_main_dock_state_before_tab_switch + if isinstance(state, QtCore.QByteArray) and not state.isEmpty(): + self.settings.setValue(_PRE_DOCK_STATE_KEY, state) + except Exception: + pass + self._save_pre_tab_groups_to_settings(self._pre_tab_groups_before_tab_switch) + + try: + self.settings.sync() + except Exception: + pass + + def _apply_pre_main_dock_snapshot_if_needed(self) -> None: + if self._use_pg_dockarea_pre_layout: + self._pre_snapshot_applied = True + return + if self._force_fixed_dock_layouts: + self._pre_snapshot_applied = True + return + if self._pre_snapshot_applied: + return + try: + raw = self.settings.value(_PRE_DOCK_STATE_KEY, None) + state = self._to_qbytearray(raw) + if state is None or state.isEmpty(): + self._pre_snapshot_applied = True + return + + ok = self.restoreDockSnapshotForTab("pre", state) + if ok: + self._pre_snapshot_applied = True + self._pre_snapshot_retry_attempts = 0 + self._sync_section_button_states_from_docks() + _LOG.info("Pre dock snapshot applied successfully") + return + + self._pre_snapshot_retry_attempts += 1 + _LOG.warning( + "Pre dock snapshot restore failed (attempt %s/%s)", + self._pre_snapshot_retry_attempts, + self._pre_snapshot_max_retries, + ) + if self._pre_snapshot_retry_attempts >= self._pre_snapshot_max_retries: + # Incompatible payload (old object names or stale version): drop and continue + # with per-dock fallback settings. + self.settings.remove(_PRE_DOCK_STATE_KEY) + self._pre_snapshot_applied = True + return + + delay = 0 if self._post_docks_ready else 120 + self._schedule_pre_snapshot_retry(delay) + except Exception: + _LOG.exception("Pre dock snapshot restore raised unexpectedly") + + def _apply_pre_default_layout_if_missing(self) -> None: + """Set a sensible preprocessing dock layout when no saved layout exists.""" + if self._use_pg_dockarea_pre_layout: + self._setup_section_popups() + self._restore_pre_dockarea_layout_state() + self._save_panel_layout_state() + return + try: + if self.settings.contains(_PRE_DOCK_STATE_KEY): + return + has_any = False + for key in self._section_docks.keys(): + if self.settings.contains(f"pre_section_docks/{key}/visible"): + has_any = True + break + if self.settings.contains("pre_artifact_dock_state/visible"): + has_any = True + if has_any: + return + except Exception: + return + + if not self._section_docks: + return + + right = QtCore.Qt.DockWidgetArea.RightDockWidgetArea + bottom = QtCore.Qt.DockWidgetArea.BottomDockWidgetArea + self._suspend_panel_layout_persistence = True + try: + self._enforce_postprocessing_popups_hidden() + # Default preprocessing layout: + # - top tab group: Artifacts list / Artifacts / Filtering / Baseline / Output + # - middle: QC + # - bottom: Export + # - bottom strip: Configuration + artifacts = self._section_docks.get("artifacts") + filtering = self._section_docks.get("filtering") + baseline = self._section_docks.get("baseline") + output = self._section_docks.get("output") + qc = self._section_docks.get("qc") + export = self._section_docks.get("export") + config = self._section_docks.get("config") + + for dock in (self.art_dock, artifacts, filtering, baseline, output, qc, export, config): + if dock is None: + continue + dock.setFloating(False) + dock.show() + + self.addDockWidget(right, self.art_dock) + for dock in (artifacts, filtering, baseline, output, qc, export): + if dock is not None: + self.addDockWidget(right, dock) + + if qc is not None: + self.splitDockWidget(self.art_dock, qc, QtCore.Qt.Orientation.Vertical) + if export is not None: + if qc is not None: + self.splitDockWidget(qc, export, QtCore.Qt.Orientation.Vertical) + else: + self.splitDockWidget(self.art_dock, export, QtCore.Qt.Orientation.Vertical) + + if config is not None: + self.addDockWidget(bottom, config) + config.raise_() + + for dock in (artifacts, filtering, baseline, output): + if dock is not None: + self.tabifyDockWidget(self.art_dock, dock) + self.art_dock.raise_() + if qc is not None: + qc.raise_() + if export is not None: + export.raise_() + + self._sync_section_button_states_from_docks() + finally: + self._suspend_panel_layout_persistence = False + + self._save_panel_layout_state() + self._store_pre_main_dock_snapshot() + + def _apply_pre_fixed_layout(self) -> None: + """ + Force a deterministic preprocessing dock layout matching the project default: + - Right column top: Artifacts list tab group + (Artifacts list / Artifacts / Filtering / Baseline / Output) + - Right column middle: QC + - Right column bottom: Export + - Bottom strip: Configuration + """ + if self._use_pg_dockarea_pre_layout: + self._setup_section_popups() + if not self._pre_dockarea_fixed_layout_applied: + self._apply_pre_fixed_dockarea_layout() + else: + self._sync_section_button_states_from_docks() + return + if not self._section_docks: + return + + host = self + right = QtCore.Qt.DockWidgetArea.RightDockWidgetArea + bottom = QtCore.Qt.DockWidgetArea.BottomDockWidgetArea + + artifacts = self._section_docks.get("artifacts") + filtering = self._section_docks.get("filtering") + baseline = self._section_docks.get("baseline") + output = self._section_docks.get("output") + qc = self._section_docks.get("qc") + export = self._section_docks.get("export") + config = self._section_docks.get("config") + + self._suspend_panel_layout_persistence = True + try: + self._hide_dock_widgets(self.getPostDockWidgets(), remove=True) + # Attach all preprocessing docks in a deterministic non-floating state first. + ordered_right: List[QtWidgets.QDockWidget] = [] + if isinstance(self.art_dock, QtWidgets.QDockWidget): + ordered_right.append(self.art_dock) + for dock in (artifacts, filtering, baseline, output, qc, export): + if isinstance(dock, QtWidgets.QDockWidget): + ordered_right.append(dock) + + for dock in ordered_right: + dock.blockSignals(True) + try: + dock.setFloating(False) + host.addDockWidget(right, dock) + dock.show() + finally: + dock.blockSignals(False) + + if isinstance(config, QtWidgets.QDockWidget): + config.blockSignals(True) + try: + config.setFloating(False) + host.addDockWidget(bottom, config) + config.show() + finally: + config.blockSignals(False) + + # Vertical stack in right area: top tab group -> QC -> Export. + if qc is not None: + host.splitDockWidget(self.art_dock, qc, QtCore.Qt.Orientation.Vertical) + if export is not None: + if qc is not None: + host.splitDockWidget(qc, export, QtCore.Qt.Orientation.Vertical) + else: + host.splitDockWidget(self.art_dock, export, QtCore.Qt.Orientation.Vertical) + + # Top tab group: Artifacts list + Artifacts + Filtering + Baseline + Output. + if artifacts is not None: + host.tabifyDockWidget(self.art_dock, artifacts) + if filtering is not None: + host.tabifyDockWidget(self.art_dock, filtering) + if baseline is not None: + host.tabifyDockWidget(self.art_dock, baseline) + if output is not None: + host.tabifyDockWidget(self.art_dock, output) + + # Keep active tabs consistent with the default arrangement. + try: + self.art_dock.raise_() + except Exception: + pass + if qc is not None: + qc.raise_() + if export is not None: + export.raise_() + if config is not None: + config.raise_() + + # Approximate default height proportions for right-column groups. + try: + vdocks: List[QtWidgets.QDockWidget] = [] + sizes: List[int] = [] + if isinstance(self.art_dock, QtWidgets.QDockWidget): + vdocks.append(self.art_dock) + sizes.append(560) + if qc is not None: + vdocks.append(qc) + sizes.append(220) + if export is not None: + vdocks.append(export) + sizes.append(120) + if vdocks and sizes: + host.resizeDocks(vdocks, sizes, QtCore.Qt.Orientation.Vertical) + except Exception: + pass + + self._sync_section_button_states_from_docks() + finally: + self._suspend_panel_layout_persistence = False + + def _restore_preprocessing_popups_after_tab_switch(self) -> None: + if self._use_pg_dockarea_pre_layout: + return + if not self._pre_popups_hidden_by_tab_switch: + return + + host = self + restored_from_snapshot = False + self._suspend_panel_layout_persistence = True + try: + snapshot = self._pre_main_dock_state_before_tab_switch + if ( + not self._force_fixed_dock_layouts + and isinstance(snapshot, QtCore.QByteArray) + and not snapshot.isEmpty() + ): + try: + restored_from_snapshot = self.restoreDockSnapshotForTab("pre", snapshot) + except Exception: + restored_from_snapshot = False + if restored_from_snapshot: + self._sync_section_button_states_from_docks() + if self._pre_advanced_visible_before_tab_switch and self._advanced_dialog is not None: + self._advanced_dialog.show() + self._advanced_dialog.raise_() + self._advanced_dialog.activateWindow() + else: + for key, dock in self._section_docks.items(): + state = self._pre_section_state_before_tab_switch.get(key, {}) + visible = bool(state.get("visible", self._pre_section_visibility_before_tab_switch.get(key, False))) + floating = bool(state.get("floating", dock.isFloating())) + area = self._dock_area_from_settings( + state.get("area", _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2)), + QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + ) + geom = state.get("geometry", None) + + dock.blockSignals(True) + try: + if floating: + dock.setFloating(True) + else: + host.addDockWidget(area, dock) + dock.setFloating(False) + if isinstance(geom, QtCore.QByteArray) and not geom.isEmpty(): + dock.restoreGeometry(geom) + self._section_popup_initialized.add(key) + if visible: + if dock.isFloating() and not self._is_popup_on_screen(dock): + self._position_section_popup(dock) + dock.show() + self._set_section_button_checked(key, True) + self._last_opened_section = key + else: + dock.hide() + self._set_section_button_checked(key, False) + finally: + dock.blockSignals(False) + + art_state = self._pre_artifact_state_before_tab_switch or {} + art_visible = bool(art_state.get("visible", self._pre_artifact_visible_before_tab_switch)) + art_floating = bool(art_state.get("floating", self.art_dock.isFloating())) + art_area = self._dock_area_from_settings( + art_state.get("area", _dock_area_to_int(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, 2)), + QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + ) + art_geom = art_state.get("geometry", None) + if art_floating: + self.art_dock.setFloating(True) + else: + host.addDockWidget(art_area, self.art_dock) + self.art_dock.setFloating(False) + if isinstance(art_geom, QtCore.QByteArray) and not art_geom.isEmpty(): + self.art_dock.restoreGeometry(art_geom) + self.art_dock.setVisible(art_visible) + + if self._pre_advanced_visible_before_tab_switch and self._advanced_dialog is not None: + self._advanced_dialog.show() + self._advanced_dialog.raise_() + self._advanced_dialog.activateWindow() + self._restore_pre_tab_groups_fallback(self._pre_tab_groups_before_tab_switch) + finally: + self._suspend_panel_layout_persistence = False + + self._pre_popups_hidden_by_tab_switch = False + self._pre_section_visibility_before_tab_switch.clear() + self._pre_section_state_before_tab_switch.clear() + self._pre_artifact_visible_before_tab_switch = False + self._pre_artifact_state_before_tab_switch.clear() + self._pre_advanced_visible_before_tab_switch = False + self._pre_main_dock_state_before_tab_switch = None + self._pre_tab_groups_before_tab_switch = [] + self._enforce_postprocessing_popups_hidden() + self._save_panel_layout_state() + + def _restore_window_state_after_tab_switch(self, was_fullscreen: bool, was_maximized: bool) -> None: + """ + Keep the top-level window mode stable across heavy dock add/remove/tabify + operations triggered by main-tab switches. + """ + if was_fullscreen: + if self.isFullScreen(): + return + + def _ensure_fullscreen() -> None: + if not self.isFullScreen(): + self.showFullScreen() + + try: + _ensure_fullscreen() + QtCore.QTimer.singleShot(0, _ensure_fullscreen) + QtCore.QTimer.singleShot(120, _ensure_fullscreen) + except Exception: + pass + return - def _restore_settings(self) -> None: - last_dir = self.settings.value("last_open_dir", "", type=str) - if last_dir and os.path.isdir(last_dir): - self.file_panel.set_path_hint(last_dir) + if was_maximized and not self.isMaximized() and not self.isFullScreen(): + try: + self.showMaximized() + except Exception: + pass - # restore params + def _apply_fixed_post_layout_deferred(self) -> None: + if not hasattr(self, "tabs") or self.tabs.currentWidget() is not self.post_tab: + return try: - raw = self.settings.value("params_json", "", type=str) - if raw: - d = json.loads(raw) - # One-time migration: ensure invert polarity defaults to off - migrated = self.settings.value("invert_polarity_migrated", False, type=bool) - if not migrated: - d["invert_polarity"] = False - self.settings.setValue("invert_polarity_migrated", True) - self.settings.setValue("params_json", json.dumps(d)) - p = ProcessingParams.from_dict(d) - self.param_panel.set_params(p) + self.post_tab.ensure_section_popups_initialized() + if hasattr(self.post_tab, "apply_fixed_default_layout"): + self.post_tab.apply_fixed_default_layout() + except Exception: + _LOG.exception("Failed to apply fixed post layout on tab switch") + return + try: + self._enforce_only_tab_docks_visible("post") except Exception: pass - # restore splitter sizes + def _enforce_fixed_layout_for_active_tab(self) -> None: + if not self._force_fixed_dock_layouts or not hasattr(self, "tabs"): + return try: - splitter_sizes = self.settings.value("splitter_sizes", None) - if splitter_sizes and hasattr(splitter_sizes, '__len__') and len(splitter_sizes) >= 2: - # Find the splitter in the preprocessing tab - pre_tab = self.tabs.widget(0) # Assuming preprocessing is tab 0 - if pre_tab: - splitter = pre_tab.findChild(QtWidgets.QSplitter) - if splitter: - splitter.setSizes([int(splitter_sizes[0]), int(splitter_sizes[1])]) + current = self.tabs.currentWidget() except Exception: - pass + return + try: + if current is self.pre_tab: + self._apply_pre_fixed_layout() + self._enforce_only_tab_docks_visible("pre") + elif current is self.post_tab: + self._apply_fixed_post_layout_deferred() + self._enforce_only_tab_docks_visible("post") + except Exception: + _LOG.exception("Failed to enforce fixed layout for active tab") - def _save_settings(self) -> None: + def _on_main_tab_changed(self, index: int) -> None: + if self._handling_main_tab_change: + self._pending_main_tab_index = int(index) + return + self._handling_main_tab_change = True + was_fullscreen = bool(self.isFullScreen()) + was_maximized = bool(self.isMaximized()) try: - last_dir = self.file_panel.current_dir_hint() - if last_dir: - self.settings.setValue("last_open_dir", last_dir) + current = self.tabs.widget(index) + if self._force_fixed_dock_layouts: + try: + if current is self.pre_tab: + try: + self.post_tab.hide_section_popups_for_tab_switch() + except Exception: + pass + self._enforce_postprocessing_popups_hidden() + self._restore_preprocessing_popups_after_tab_switch() + self._apply_pre_fixed_layout() + self._enforce_only_tab_docks_visible("pre") + else: + self._hide_preprocessing_popups_for_tab_switch() + self._enforce_preprocessing_popups_hidden() + QtCore.QTimer.singleShot(0, self._enforce_preprocessing_popups_hidden) + self._apply_fixed_post_layout_deferred() + QtCore.QTimer.singleShot(0, self._apply_fixed_post_layout_deferred) + QtCore.QTimer.singleShot(120, self._apply_fixed_post_layout_deferred) + self._enforce_only_tab_docks_visible("post") + try: + self._save_panel_layout_state() + except Exception: + pass + if current is self.pre_tab: + try: + self._store_pre_main_dock_snapshot() + except Exception: + pass + else: + try: + self._persist_hidden_preprocessing_layout_state() + except Exception: + pass + if current is self.pre_tab: + try: + self.post_tab.persist_layout_state_snapshot() + except Exception: + pass + self._save_settings() + self._save_panel_config_json() + except Exception: + _LOG.exception("Failed to handle fixed-layout tab switch") + return + try: + if current is self.pre_tab: + try: + self.post_tab.hide_section_popups_for_tab_switch() + except Exception: + pass + self._enforce_postprocessing_popups_hidden() + self._restore_preprocessing_popups_after_tab_switch() + self._apply_pre_main_dock_snapshot_if_needed() + self._enforce_only_tab_docks_visible("pre") + else: + self._hide_preprocessing_popups_for_tab_switch() + # Run once now and once after queued dock events so preprocessing panels + # cannot bleed into Post Processing. + self._enforce_preprocessing_popups_hidden() + QtCore.QTimer.singleShot(0, self._enforce_preprocessing_popups_hidden) + self._enforce_only_tab_docks_visible("post") + # Persist active main tab immediately. + self._save_settings() + # Persist panel layout JSON on each tab switch. + self._save_panel_config_json() + except Exception: + _LOG.exception("Failed to handle main tab switch") + finally: + self._restore_window_state_after_tab_switch(was_fullscreen, was_maximized) + self._handling_main_tab_change = False + if self._force_fixed_dock_layouts: + QtCore.QTimer.singleShot(0, self._enforce_fixed_layout_for_active_tab) + QtCore.QTimer.singleShot(80, self._enforce_fixed_layout_for_active_tab) + if self._pending_main_tab_index is not None: + pending = int(self._pending_main_tab_index) + self._pending_main_tab_index = None + QtCore.QTimer.singleShot(0, lambda idx=pending: self._on_main_tab_changed(idx)) + + def _on_artifact_overlay_toggled(self, visible: bool) -> None: + self.plots.set_artifact_overlay_visible(bool(visible)) + self._save_settings() + + def _on_artifact_thresholds_toggled(self, visible: bool) -> None: + self.plots.set_artifact_thresholds_visible(bool(visible)) + self._save_settings() + + def _normalize_app_theme_mode(self, value: object) -> str: + mode = str(value or "").strip().lower() + if mode in {"light", "white", "l", "w"}: + return "light" + return "dark" + + def _selected_app_theme_mode(self) -> str: + if hasattr(self, "act_app_theme_light") and self.act_app_theme_light.isChecked(): + return "light" + return "dark" + + def _apply_app_theme(self, theme_mode: object, persist: bool = True) -> None: + mode = self._normalize_app_theme_mode(theme_mode) + self._app_theme_mode = mode + + if hasattr(self, "act_app_theme_dark"): + self.act_app_theme_dark.blockSignals(True) + self.act_app_theme_dark.setChecked(mode == "dark") + self.act_app_theme_dark.blockSignals(False) + if hasattr(self, "act_app_theme_light"): + self.act_app_theme_light.blockSignals(True) + self.act_app_theme_light.setChecked(mode == "light") + self.act_app_theme_light.blockSignals(False) + + try: + self.setStyleSheet(app_qss(mode)) except Exception: pass + pre_bg = "white" if mode == "light" else "dark" + pre_grid = self.act_plot_grid.isChecked() if hasattr(self, "act_plot_grid") else True + self._apply_pre_plot_style(pre_bg, pre_grid, persist=False) + try: - p = self.param_panel.get_params() - self.settings.setValue("params_json", json.dumps(p.to_dict())) + if hasattr(self.post_tab, "set_app_theme_mode"): + self.post_tab.set_app_theme_mode(mode) except Exception: pass - def _save_splitter_sizes(self) -> None: - """Save the current splitter sizes to settings.""" + if persist: + self._save_settings() + + def _on_app_theme_changed(self, *_args) -> None: + self._apply_app_theme(self._selected_app_theme_mode(), persist=True) + + def _normalize_pre_plot_background(self, value: object) -> str: + mode = str(value or "").strip().lower() + if mode in {"white", "light", "w"}: + return "white" + return "dark" + + def _selected_pre_plot_background(self) -> str: + if hasattr(self, "act_plot_bg_white") and self.act_plot_bg_white.isChecked(): + return "white" + return "dark" + + def _apply_pre_plot_style(self, background: object, show_grid: object, persist: bool = True) -> None: + mode = self._normalize_pre_plot_background(background) + grid = bool(show_grid) + if hasattr(self, "act_plot_bg_dark"): + self.act_plot_bg_dark.blockSignals(True) + self.act_plot_bg_dark.setChecked(mode == "dark") + self.act_plot_bg_dark.blockSignals(False) + if hasattr(self, "act_plot_bg_white"): + self.act_plot_bg_white.blockSignals(True) + self.act_plot_bg_white.setChecked(mode == "white") + self.act_plot_bg_white.blockSignals(False) + if hasattr(self, "act_plot_grid"): + self.act_plot_grid.blockSignals(True) + self.act_plot_grid.setChecked(grid) + self.act_plot_grid.blockSignals(False) try: - # Find the splitter in the preprocessing tab - pre_tab = self.tabs.widget(0) # Assuming preprocessing is tab 0 - if pre_tab: - splitter = pre_tab.findChild(QtWidgets.QSplitter) - if splitter: - sizes = splitter.sizes() - self.settings.setValue("splitter_sizes", sizes) + self.plots.set_plot_appearance(mode, grid) except Exception: pass - - # ---------------- File loading ---------------- - - def _open_files_dialog(self) -> None: - start_dir = self.file_panel.current_dir_hint() or self.settings.value("last_open_dir", "", type=str) or os.getcwd() - paths, _ = QtWidgets.QFileDialog.getOpenFileNames( - self, - "Open files", - start_dir, - "Doric files (*.doric *.h5 *.hdf5);;All files (*.*)", + if persist: + self._save_settings() + + def _on_pre_plot_style_changed(self, *_args) -> None: + self._apply_pre_plot_style( + self._selected_pre_plot_background(), + self.act_plot_grid.isChecked() if hasattr(self, "act_plot_grid") else True, + persist=True, ) - if not paths: - return - - self.settings.setValue("last_open_dir", os.path.dirname(paths[0])) - self._add_files(paths) - - def _open_folder_dialog(self) -> None: - start_dir = self.file_panel.current_dir_hint() or self.settings.value("last_open_dir", "", type=str) or os.getcwd() - folder = QtWidgets.QFileDialog.getExistingDirectory(self, "Add folder with .doric", start_dir) - if not folder: - return - self.settings.setValue("last_open_dir", folder) - paths: List[str] = [] - for fn in os.listdir(folder): - if fn.lower().endswith((".doric", ".h5", ".hdf5")): - paths.append(os.path.join(folder, fn)) - paths.sort() - self._add_files(paths) + def _auto_range_for_processed(self, processed: ProcessedTrial) -> None: + try: + start_s, end_s = self._time_window_bounds() + t = np.asarray(processed.time, float) + if t.size > 1: + x0 = float(np.nanmin(t)) if start_s is None else float(start_s) + x1 = float(np.nanmax(t)) if end_s is None else float(end_s) + if np.isfinite(x0) and np.isfinite(x1) and x1 > x0: + self.plots.auto_range_all(x0=x0, x1=x1) + else: + self.plots.auto_range_all() + else: + self.plots.auto_range_all() + except Exception: + self.plots.auto_range_all() - def _add_files(self, paths: List[str]) -> None: - for p in paths: - if p in self._loaded_files: - continue - try: - doric = self.processor.load_file(p) - self._loaded_files[p] = doric - self.file_panel.add_file(p) - self.plots.set_log(f"Loaded: {p}") - except Exception as e: - QtWidgets.QMessageBox.critical(self, "Load error", f"Failed to load:\n{p}\n\n{e}") + def _fmt_fs(self, fs: Optional[float]) -> str: + if fs is None or not np.isfinite(float(fs)): + return "-" + return f"{float(fs):.2f}" - # set current selection -> triggers preview - self._on_file_selection_changed() + def _current_fs_actual(self) -> Optional[float]: + key = self._current_key() + if key: + proc = self._last_processed.get(key) + if proc is not None and np.isfinite(float(getattr(proc, "fs_actual", np.nan))): + return float(proc.fs_actual) + if not self._current_path or not self._current_channel: + return None + doric = self._loaded_files.get(self._current_path) + if doric is None: + return None + try: + trial = doric.make_trial(self._current_channel, trigger_name=self._current_trigger) + trial = self._apply_time_window(trial) + fs = float(trial.sampling_rate) + return fs if np.isfinite(fs) else None + except Exception: + return None - # ---------------- Current selection ---------------- + def _show_status_message(self, message: str, timeout_ms: int = 0) -> None: + sb = getattr(self, "_status_bar", None) + if not isinstance(sb, QtWidgets.QStatusBar): + attr = getattr(self, "statusBar", None) + if callable(attr): + try: + sb = attr() + except Exception: + sb = None + elif isinstance(attr, QtWidgets.QStatusBar): + sb = attr + if not isinstance(sb, QtWidgets.QStatusBar): + return + try: + sb.showMessage(str(message), int(timeout_ms)) + except Exception: + pass - def _selected_paths(self) -> List[str]: - return self.file_panel.selected_paths() + def _update_plot_status(self, fs_actual: Optional[float] = None, fs_target: Optional[float] = None) -> None: + channel = self._current_channel or "-" + trig = self._current_trigger or "None" + mode = "-" + target = fs_target + try: + p = self.param_panel.get_params() + mode = str(p.output_mode) + if target is None: + target = float(p.target_fs_hz) + except Exception: + pass + if fs_actual is None: + fs_actual = self._current_fs_actual() - def _current_key(self) -> Optional[Tuple[str, str]]: - if not self._current_path or not self._current_channel: - return None - return (self._current_path, self._current_channel) + status = ( + f"Channel: {channel} | A/D: {trig} | Fs: {self._fmt_fs(fs_actual)} -> " + f"{self._fmt_fs(target)} Hz | Mode: {mode}" + ) + self._show_status_message(status, 30000) def _on_file_selection_changed(self) -> None: sel = self._selected_paths() @@ -459,6 +3380,9 @@ def _on_file_selection_changed(self) -> None: if not sel: self._current_path = None self._current_channel = None + self._current_trigger = None + self.plots.set_title("No file loaded") + self._update_plot_status() return # preview shows first selected @@ -470,7 +3394,10 @@ def _on_file_selection_changed(self) -> None: return self.file_panel.set_available_channels(doric.channels) - self.file_panel.set_available_triggers(sorted(doric.digital_by_name.keys())) + self.file_panel.set_available_triggers(sorted(doric.trigger_by_name.keys())) + self.param_panel.set_available_export_channels(doric.channels) + self.param_panel.set_available_export_triggers(sorted(doric.trigger_by_name.keys())) + self._update_export_summary_label() # keep channel if still valid if self._current_channel in doric.channels: @@ -481,25 +3408,45 @@ def _on_file_selection_changed(self) -> None: self.file_panel.set_channel(self._current_channel) # keep trigger if still valid - if self._current_trigger and self._current_trigger not in doric.digital_by_name: + if self._current_trigger and self._current_trigger not in doric.trigger_by_name: self._current_trigger = None self.file_panel.set_trigger("") + self._update_export_summary_label() self._update_raw_plot() self._trigger_preview() # update post tab selection context self.post_tab.set_current_source_label(os.path.basename(path), self._current_channel or "") + self._update_plot_status() def _on_channel_changed(self, ch: str) -> None: self._current_channel = ch + if self._current_path: + doric = self._loaded_files.get(self._current_path) + if doric is not None: + self.param_panel.set_available_export_channels( + doric.channels, + preferred=self.param_panel.export_channel_names(), + ) + self._update_export_summary_label() self._update_raw_plot() self._trigger_preview() self.post_tab.set_current_source_label(os.path.basename(self._current_path or ""), self._current_channel or "") + self._update_plot_status() def _on_trigger_changed(self, trig: str) -> None: self._current_trigger = trig if trig else None + if self._current_path: + doric = self._loaded_files.get(self._current_path) + if doric is not None: + self.param_panel.set_available_export_triggers( + sorted(doric.trigger_by_name.keys()), + preferred=self.param_panel.export_trigger_names(), + ) + self._update_export_summary_label() self._update_raw_plot() + self._update_plot_status() def _on_time_window_changed(self) -> None: self._last_processed.clear() @@ -514,6 +3461,7 @@ def _on_time_window_changed(self) -> None: self.artifact_panel.set_regions(manual_win) self._update_raw_plot() self._trigger_preview() + self._update_plot_status() def _open_advanced_options(self) -> None: key = self._current_key() @@ -724,6 +3672,20 @@ def _mask_arr(arr: Optional[np.ndarray], use_time_mask: bool) -> Optional[np.nda fs = 1.0 / float(np.nanmedian(np.diff(time))) if time.size > 2 else np.nan + new_triggers = {} + new_trigger_times = {} + if hasattr(trial, "triggers") and trial.triggers: + for name, val in trial.triggers.items(): + vt = trial.trigger_times.get(name) + if vt is not None: + if vt.size == t.size: + new_triggers[name] = np.asarray(val, float)[mask] + new_trigger_times[name] = np.asarray(vt, float)[mask] + else: + tmask = (vt >= float(start_s)) & (vt <= float(end_s)) + new_triggers[name] = np.asarray(val, float)[tmask] + new_trigger_times[name] = np.asarray(vt, float)[tmask] + return LoadedTrial( path=trial.path, channel_id=trial.channel_id, @@ -734,6 +3696,8 @@ def _mask_arr(arr: Optional[np.ndarray], use_time_mask: bool) -> Optional[np.nda trigger_time=trig_time, trigger=trig, trigger_name=trial.trigger_name, + triggers=new_triggers, + trigger_times=new_trigger_times, ) def _apply_cutouts(self, trial: LoadedTrial, cutouts: List[Tuple[float, float]]) -> LoadedTrial: @@ -756,6 +3720,8 @@ def _apply_cutouts(self, trial: LoadedTrial, cutouts: List[Tuple[float, float]]) trigger_time=trial.trigger_time, trigger=trial.trigger, trigger_name=trial.trigger_name, + triggers=dict(trial.triggers) if hasattr(trial, "triggers") else {}, + trigger_times=dict(trial.trigger_times) if hasattr(trial, "trigger_times") else {}, ) def _apply_cutouts_to_processed(self, processed: ProcessedTrial, cutouts: List[Tuple[float, float]]) -> ProcessedTrial: @@ -776,15 +3742,21 @@ def _mask_arr(arr: Optional[np.ndarray]) -> Optional[np.ndarray]: y[mask] = np.nan return y - raw_sig = _mask_arr(processed.raw_signal) - raw_ref = _mask_arr(processed.raw_reference) - processed.raw_signal = raw_sig if raw_sig is not None else processed.raw_signal - processed.raw_reference = raw_ref if raw_ref is not None else processed.raw_reference + processed.raw_signal = _mask_arr(processed.raw_signal) + processed.raw_reference = _mask_arr(processed.raw_reference) processed.sig_f = _mask_arr(processed.sig_f) processed.ref_f = _mask_arr(processed.ref_f) processed.baseline_sig = _mask_arr(processed.baseline_sig) processed.baseline_ref = _mask_arr(processed.baseline_ref) processed.output = _mask_arr(processed.output) + + # Mask triggers too if requested by convention, but here we keep them as-is or NaN them + if hasattr(processed, "triggers") and processed.triggers: + new_triggers = {} + for name, val in processed.triggers.items(): + new_triggers[name] = _mask_arr(val) + processed.triggers = new_triggers + return processed def _slice_trial(self, trial: LoadedTrial, start_s: float, end_s: float) -> Optional[LoadedTrial]: @@ -805,6 +3777,21 @@ def _slice_trial(self, trial: LoadedTrial, start_s: float, end_s: float) -> Opti tmask = (trig_time >= float(start_s)) & (trig_time <= float(end_s)) trig_time = np.asarray(trig_time, float)[tmask] trig = np.asarray(trig, float)[tmask] + + new_triggers = {} + new_trigger_times = {} + if hasattr(trial, "triggers") and trial.triggers: + for name, val in trial.triggers.items(): + vt = trial.trigger_times.get(name) + if vt is not None: + if vt.size == t.size: + new_triggers[name] = np.asarray(val, float)[mask] + new_trigger_times[name] = np.asarray(vt, float)[mask] + else: + tmask = (vt >= float(start_s)) & (vt <= float(end_s)) + new_triggers[name] = np.asarray(val, float)[tmask] + new_trigger_times[name] = np.asarray(vt, float)[tmask] + fs = 1.0 / float(np.nanmedian(np.diff(time))) if time.size > 2 else np.nan return LoadedTrial( path=trial.path, @@ -816,9 +3803,11 @@ def _slice_trial(self, trial: LoadedTrial, start_s: float, end_s: float) -> Opti trigger_time=trig_time, trigger=trig, trigger_name=trial.trigger_name, + triggers=new_triggers, + trigger_times=new_trigger_times, ) - def _update_raw_plot(self) -> None: + def _update_raw_plot(self, preserve_view: bool = False) -> None: if not self._current_path or not self._current_channel: return doric = self._loaded_files.get(self._current_path) @@ -840,7 +3829,7 @@ def _update_raw_plot(self) -> None: raw465 = -np.asarray(raw465, float) raw405 = -np.asarray(raw405, float) - self.plots.set_title(os.path.basename(self._current_path)) + self.plots.set_title("raw signal") self.plots.show_raw( time=trial.time, raw465=raw465, @@ -849,7 +3838,9 @@ def _update_raw_plot(self) -> None: trig=trial.trigger, trig_label=self._current_trigger or "", manual_regions=manual, + preserve_view=preserve_view, ) + self._update_plot_status(fs_actual=float(trial.sampling_rate), fs_target=float(params.target_fs_hz)) # ---------------- Preview processing (worker) ---------------- @@ -866,8 +3857,9 @@ def _on_params_changed(self) -> None: try: params = self.param_panel.get_params() except Exception: - self._trigger_preview() + self._trigger_preview(preserve_view=True) return + self._update_plot_status(fs_target=float(params.target_fs_hz)) sig = self._artifact_param_signature(params) if self._last_artifact_params is None: self._last_artifact_params = sig @@ -882,14 +3874,15 @@ def _on_params_changed(self) -> None: self.artifact_panel.set_auto_regions(auto, checked_regions=auto) # Update raw display for toggles like polarity inversion try: - self._update_raw_plot() + self._update_raw_plot(preserve_view=True) except Exception: pass - self._trigger_preview() + self._trigger_preview(preserve_view=True) - def _trigger_preview(self) -> None: + def _trigger_preview(self, preserve_view: bool = False) -> None: # persist params quickly self._save_settings() + self._preview_preserve_view_pending = bool(preserve_view) self._preview_timer.start() def _start_preview_processing(self) -> None: @@ -912,11 +3905,15 @@ def _start_preview_processing(self) -> None: self._job_counter += 1 job_id = self._job_counter self._latest_job_id = job_id + preserve_view = bool(self._preview_preserve_view_pending) + self._preview_preserve_view_pending = False + self._preview_preserve_view_by_job[job_id] = preserve_view - self.plots.set_log( - f"Processing preview… (fs={trial.sampling_rate:.2f} Hz → target {params.target_fs_hz:.1f} Hz, " + self._show_status_message( + f"Processing preview... (fs={trial.sampling_rate:.2f} Hz -> target {params.target_fs_hz:.1f} Hz, " f"baseline={params.baseline_method})" ) + self._update_plot_status(fs_actual=float(trial.sampling_rate), fs_target=float(params.target_fs_hz)) task = self.processor.make_preview_task( trial=trial, @@ -931,6 +3928,7 @@ def _start_preview_processing(self) -> None: @QtCore.Slot(object, int, float) def _on_preview_finished(self, processed: ProcessedTrial, job_id: int, elapsed_s: float) -> None: + preserve_view = bool(self._preview_preserve_view_by_job.pop(job_id, False)) if job_id != self._latest_job_id: return # ignore stale jobs @@ -951,40 +3949,29 @@ def _on_preview_finished(self, processed: ProcessedTrial, job_id: int, elapsed_s manual_regs = self._clip_regions_to_window(self._manual_regions_by_key.get(key, []), start_s, end_s) self.artifact_panel.set_regions(manual_regs) - # Preserve current x/y ranges before updating plots - current_xrange = None - try: - # Get current x range from the first plot - view_box = self.plots.plot_raw.getViewBox() - if view_box: - x_range = view_box.viewRange()[0] # x axis range - current_xrange = (x_range[0], x_range[1]) - except Exception: - pass - # Update plots (decimated signals) - self.plots.update_plots(processed) - - # Restore x range if it was preserved - if current_xrange is not None: - try: - self.plots.set_xrange_all(current_xrange[0], current_xrange[1]) - except Exception: - pass + self.plots.update_plots(processed, preserve_view=preserve_view) + if not preserve_view: + # Auto-range on each update so file/time-window changes do not require manual reset. + self._auto_range_for_processed(processed) - self.plots.set_log( - f"Preview updated: {processed.output_label} | fs={processed.fs_actual:.2f}→{processed.fs_used:.2f} Hz " + log_msg = ( + f"Preview updated: {processed.output_label} | fs={processed.fs_actual:.2f}->{processed.fs_used:.2f} Hz " f"(target {processed.fs_target:.2f}) | n={processed.time.size} | {elapsed_s*1000:.0f} ms" ) + self._show_status_message(log_msg, 10000) + self.param_panel.set_fs_info(processed.fs_actual, processed.fs_target, processed.fs_used) + self._update_plot_status(fs_actual=float(processed.fs_actual), fs_target=float(processed.fs_target)) # Inform post tab that current processed changed self.post_tab.notify_preprocessing_updated(processed) @QtCore.Slot(str, int) def _on_preview_failed(self, err: str, job_id: int) -> None: + self._preview_preserve_view_by_job.pop(job_id, None) if job_id != self._latest_job_id: return - self.plots.set_log(f"Preview error: {err}") + self._show_status_message(f"Preview error: {err}") # ---------------- Manual artifacts ---------------- @@ -1061,12 +4048,10 @@ def _add_manual_region_from_drag(self, t0: float, t1: float) -> None: return if not np.isfinite(t0) or not np.isfinite(t1) or t0 == t1: return - regs = self._manual_regions_by_key.get(key, []) - regs.append((min(t0, t1), max(t0, t1))) - self._manual_regions_by_key[key] = regs - start_s, end_s = self._time_window_bounds() - self.artifact_panel.set_regions(self._clip_regions_to_window(regs, start_s, end_s)) - self._trigger_preview() + region = (float(min(t0, t1)), float(max(t0, t1))) + self._pending_box_region_by_key[key] = region + self.plots.set_selector_region(*region, visible=True) + self._show_status_message("Selection ready: press A=artifact, C=cut, S=section, or right-click for actions.") def _clear_manual_regions_current(self) -> None: key = self._current_key() @@ -1074,19 +4059,117 @@ def _clear_manual_regions_current(self) -> None: return self._manual_regions_by_key[key] = [] self._manual_exclude_by_key[key] = [] + self._pending_box_region_by_key.pop(key, None) self.artifact_panel.set_regions([]) self._trigger_preview() def _request_box_select(self, callback: Callable[[float, float], None]) -> None: self._box_select_callback = callback self.plots.btn_box_select.setChecked(True) - self.plots.set_log("Box select: drag on the raw plot to set the time window; right-click to cancel.") + self._show_status_message("Box select: drag on the raw plot to set the time window; right-click to cancel.") def _cancel_box_select_request(self) -> None: - if not self._box_select_callback: - return + key = self._current_key() self._box_select_callback = None + if key: + self._pending_box_region_by_key.pop(key, None) + self.plots.set_selector_region(0.0, 1.0, visible=False) + self.plots.btn_box_select.setChecked(False) + + def _pending_box_region(self) -> Optional[Tuple[float, float]]: + key = self._current_key() + if not key: + return None + region = self._pending_box_region_by_key.get(key) + if not region: + if not self.plots.selector_visible(): + return None + t0, t1 = self.plots.selector_region() + return (float(min(t0, t1)), float(max(t0, t1))) + return (float(min(region)), float(max(region))) + + def _consume_pending_box_region(self) -> Optional[Tuple[float, float]]: + key = self._current_key() + if not key: + return None + + is_tool_active = self.plots.btn_box_select.isChecked() + region = self._pending_box_region_by_key.pop(key, None) + + if not region: + if not self.plots.selector_visible(): + return None + t0, t1 = self.plots.selector_region() + region = (float(min(t0, t1)), float(max(t0, t1))) + # If not using the box-select tool, we do NOT hide the persistent selector. + if not is_tool_active: + return region + + # Cleanup if we were in tool mode or had a temporary drag selection. + self.plots.set_selector_region(0.0, 1.0, visible=False) self.plots.btn_box_select.setChecked(False) + return (float(min(region)), float(max(region))) + + def _assign_pending_box_to_artifact(self) -> None: + region = self._consume_pending_box_region() + key = self._current_key() + if not region or not key: + return + regs = self._manual_regions_by_key.get(key, []) + regs.append(region) + self._manual_regions_by_key[key] = regs + start_s, end_s = self._time_window_bounds() + self.artifact_panel.set_regions(self._clip_regions_to_window(regs, start_s, end_s)) + self._trigger_preview() + + def _assign_pending_box_to_cut(self) -> None: + region = self._consume_pending_box_region() + key = self._current_key() + if not region or not key: + return + regs = self._cutout_regions_by_key.get(key, []) + regs.append(region) + regs.sort(key=lambda x: x[0]) + self._cutout_regions_by_key[key] = regs + self._last_processed.clear() + self._update_raw_plot() + self._trigger_preview() + + def _assign_pending_box_to_section(self) -> None: + region = self._consume_pending_box_region() + key = self._current_key() + if not region or not key: + return + sections = self._sections_by_key.get(key, []) + sections.append({ + "start": float(region[0]), + "end": float(region[1]), + "params": self.param_panel.get_params().to_dict(), + }) + sections.sort(key=lambda sec: float(sec.get("start", 0.0))) + self._sections_by_key[key] = sections + self._show_status_message(f"Section added: {region[0]:.3f}s to {region[1]:.3f}s") + + def _show_box_selection_context_menu(self) -> None: + region = self._pending_box_region() + if region is None: + self._cancel_box_select_request() + return + menu = QtWidgets.QMenu(self) + act_art = menu.addAction("Set as artifact") + act_cut = menu.addAction("Set as cut") + act_sec = menu.addAction("Set as section") + menu.addSeparator() + act_cancel = menu.addAction("Cancel selection") + chosen = menu.exec(QtGui.QCursor.pos()) + if chosen is act_art: + self._assign_pending_box_to_artifact() + elif chosen is act_cut: + self._assign_pending_box_to_cut() + elif chosen is act_sec: + self._assign_pending_box_to_section() + elif chosen is act_cancel: + self._cancel_box_select_request() def _artifact_regions_changed(self, regions: List[Tuple[float, float]]) -> None: key = self._current_key() @@ -1108,11 +4191,32 @@ def _contains(target: Tuple[float, float], arr: List[Tuple[float, float]]) -> bo self._trigger_preview() def _toggle_artifacts_panel(self) -> None: - if self.art_dock.isVisible(): - self.art_dock.setVisible(False) + if self._use_pg_dockarea_pre_layout: + self._setup_section_popups() + dock = self._pre_dockarea_dock("artifacts_list") + if dock is None: + return + if dock.isVisible(): + dock.hide() + else: + dock.show() + try: + dock.raiseDock() + except Exception: + pass + self._last_opened_section = "artifacts_list" + self._sync_section_button_states_from_docks() + self._save_panel_layout_state() return - self.artifact_panel.show() - self.art_dock.setVisible(True) + + if isinstance(self.art_dock, QtWidgets.QDockWidget): + if self.art_dock.isVisible(): + self.art_dock.setVisible(False) + self._save_panel_layout_state() + return + self.artifact_panel.show() + self.art_dock.setVisible(True) + self._save_panel_layout_state() # ---------------- Metadata ---------------- @@ -1209,6 +4313,9 @@ def _export_selected_or_all(self) -> None: self._remember_export_dir(out_dir, origin_dir) params = self.param_panel.get_params() + export_selection = self.param_panel.export_selection() + export_channel_names = self.param_panel.export_channel_names() + export_trigger_names = self.param_panel.export_trigger_names() # Process/export each selected file, for the currently selected channel. n_total = 0 @@ -1216,62 +4323,83 @@ def _export_selected_or_all(self) -> None: doric = self._loaded_files.get(path) if not doric: continue - ch = self._current_channel if (self._current_channel in doric.channels) else (doric.channels[0] if doric.channels else None) - if not ch: - continue - key = (path, ch) - trial = doric.make_trial(ch, trigger_name=self._current_trigger) # export uses current trigger selection - trial = self._apply_time_window(trial) - cutouts = self._cutout_regions_by_key.get(key, []) - trial = self._apply_cutouts(trial, cutouts) - start_s, end_s = self._time_window_bounds() - manual = self._clip_regions_to_window(self._manual_regions_by_key.get(key, []), start_s, end_s) - manual_exclude = self._clip_regions_to_window(self._manual_exclude_by_key.get(key, []), start_s, end_s) - meta = self._metadata_by_key.get(key, {}) - sections = self._sections_by_key.get(key, []) - - def _export_one(proc: ProcessedTrial, suffix: str = "") -> None: - nonlocal n_total - proc = self._apply_cutouts_to_processed(proc, cutouts) - stem = safe_stem_from_metadata(path, ch, meta) - if suffix: - stem = f"{stem}_{suffix}" - csv_path = os.path.join(out_dir, f"{stem}.csv") - h5_path = os.path.join(out_dir, f"{stem}.h5") - export_processed_csv(csv_path, proc, metadata=meta) - export_processed_h5(h5_path, proc, metadata=meta) - n_total += 1 + channels = [name for name in export_channel_names if name in doric.channels] + if not channels: + fallback = self._current_channel if (self._current_channel in doric.channels) else (doric.channels[0] if doric.channels else None) + channels = [fallback] if fallback else [] + dio_names = [name for name in export_trigger_names if name in doric.trigger_by_name] + if not export_selection.dio: + dio_names = [None] + elif not dio_names: + dio_names = [self._current_trigger] if self._current_trigger else [None] + + for ch in channels: + if not ch: + continue + key = (path, ch) + cutouts = self._cutout_regions_by_key.get(key, []) + start_s, end_s = self._time_window_bounds() + manual = self._clip_regions_to_window(self._manual_regions_by_key.get(key, []), start_s, end_s) + manual_exclude = self._clip_regions_to_window(self._manual_exclude_by_key.get(key, []), start_s, end_s) + meta = self._metadata_by_key.get(key, {}) + sections = self._sections_by_key.get(key, []) + + # Use all selected triggers for one export per channel + # If current trigger is in dio_names, use it as primary for alignment. + # Otherwise, pick first available as primary. + primary_trigger = None + if export_selection.dio and dio_names: + primary_trigger = (self._current_trigger if self._current_trigger in dio_names else (dio_names[0] if dio_names[0] else None)) + + trial = doric.make_trial(ch, trigger_name=primary_trigger, trigger_names=(dio_names if export_selection.dio else None)) + trial = self._apply_time_window(trial) + trial = self._apply_cutouts(trial, cutouts) + + def _export_one(proc: ProcessedTrial, suffix: str = "") -> None: + nonlocal n_total + proc = self._apply_cutouts_to_processed(proc, cutouts) + stem = safe_stem_from_metadata(path, ch, meta) + if suffix: + stem = f"{stem}_{suffix}" + csv_path = os.path.join(out_dir, f"{stem}.csv") + h5_path = os.path.join(out_dir, f"{stem}.h5") + export_processed_csv(csv_path, proc, metadata=meta, selection=export_selection) + export_processed_h5(h5_path, proc, metadata=meta, selection=export_selection) + n_total += 1 - try: - if sections: - for i, sec in enumerate(sections, start=1): - s0 = float(sec.get("start", 0.0)) - s1 = float(sec.get("end", 0.0)) - sec_trial = self._slice_trial(trial, s0, s1) - if sec_trial is None: - continue - sec_params = ProcessingParams.from_dict(sec.get("params", {})) if isinstance(sec.get("params"), dict) else params + try: + if sections: + for i, sec in enumerate(sections, start=1): + s0 = float(sec.get("start", 0.0)) + s1 = float(sec.get("end", 0.0)) + sec_trial = self._slice_trial(trial, s0, s1) + if sec_trial is None: + continue + sec_params = ProcessingParams.from_dict(sec.get("params", {})) if isinstance(sec.get("params"), dict) else params + processed = self.processor.process_trial( + trial=sec_trial, + params=sec_params, + manual_regions_sec=manual, + manual_exclude_regions_sec=manual_exclude, + preview_mode=False, + ) + _export_one(processed, suffix=f"sec{i}_{s0:.2f}_{s1:.2f}") + else: processed = self.processor.process_trial( - trial=sec_trial, - params=sec_params, + trial=trial, + params=params, manual_regions_sec=manual, manual_exclude_regions_sec=manual_exclude, preview_mode=False, ) - _export_one(processed, suffix=f"sec{i}_{s0:.2f}_{s1:.2f}") - else: - processed = self.processor.process_trial( - trial=trial, - params=params, - manual_regions_sec=manual, - manual_exclude_regions_sec=manual_exclude, - preview_mode=False, + _export_one(processed) + except Exception as e: + QtWidgets.QMessageBox.warning( + self, + "Export error", + f"Failed export:\n{path} [{ch}] [{primary_trigger or 'no DIO'}]\n\n{e}", ) - _export_one(processed) - except Exception as e: - QtWidgets.QMessageBox.warning(self, "Export error", f"Failed export:\n{path} [{ch}]\n\n{e}") - - self.plots.set_log(f"Export complete: {n_total} recording(s) written to {out_dir}") + self._show_status_message(f"Export complete: {n_total} recording(s) written to {out_dir}") # optional: update post tab list by loading exported results? (user can load later) @@ -1324,7 +4452,7 @@ def _post_get_current_processed(self): @QtCore.Slot() def _post_get_current_dio_list(self): - # DIO list for current/selected files: intersection or union; easiest = union + # Analog/digital channel list for current/selected files: union. paths = self._selected_paths() if not paths: paths = [self._current_path] if self._current_path else [] @@ -1333,7 +4461,7 @@ def _post_get_current_dio_list(self): for p in paths: f = self._loaded_files.get(p) if f: - dio |= set(f.digital_by_name.keys()) + dio |= set(f.trigger_by_name.keys()) self.post_tab.receive_dio_list(sorted(dio)) @QtCore.Slot(str, str) @@ -1349,23 +4477,34 @@ def _post_get_dio_data_for_path(self, path: str, dio_name: str): if f is None: return - # digital_time may be a numpy array - if getattr(f, "digital_time", None) is None: + trigger_map = getattr(f, "trigger_by_name", None) + if not isinstance(trigger_map, dict) or dio_name not in trigger_map: return - t_dio = np.asarray(f.digital_time) - if t_dio.size == 0: + y_dio = np.asarray(trigger_map[dio_name], float) + if y_dio.size == 0: return - digital_map = getattr(f, "digital_by_name", None) - if not isinstance(digital_map, dict) or dio_name not in digital_map: + t_map = getattr(f, "trigger_time_by_name", None) + t_dio = None + if isinstance(t_map, dict): + t_dio = t_map.get(dio_name) + if t_dio is None and getattr(f, "digital_time", None) is not None and dio_name in getattr(f, "digital_by_name", {}): + t_dio = f.digital_time + if t_dio is None: + # Fallback: use any analog channel timebase with matching length. + for t_candidate in getattr(f, "time_by_channel", {}).values(): + arr = np.asarray(t_candidate, float) + if arr.size == y_dio.size: + t_dio = arr + break + if t_dio is None: return - - y_dio = np.asarray(digital_map[dio_name]) - if y_dio.size == 0: + t_dio = np.asarray(t_dio, float) + if t_dio.size == 0: return - # Ensure same length + # Ensure same length. n = min(t_dio.size, y_dio.size) t_dio = t_dio[:n] y_dio = y_dio[:n] @@ -1398,6 +4537,7 @@ def _can_accept_drop(self, paths: List[str]) -> bool: def _handle_drop(self, paths: List[str]) -> None: doric_paths: List[str] = [] processed: List[ProcessedTrial] = [] + pre_active = bool(hasattr(self, "tabs") and self.tabs.currentWidget() is self.pre_tab) for p in paths: if not p: @@ -1412,6 +4552,11 @@ def _handle_drop(self, paths: List[str]) -> None: processed.append(trial) continue if ext in (".h5", ".hdf5"): + if pre_active: + # On preprocessing tab, treat dropped H5 as a raw-input candidate. + # _add_files() will load Doric H5 natively and falls back to processed H5 import. + doric_paths.append(p) + continue trial = self._load_processed_h5(p) if trial is not None: processed.append(trial) @@ -1458,7 +4603,24 @@ def _find_col(names: List[str]) -> Optional[int]: return None time_idx = header.index("time") if "time" in header else None - output_idx = _find_col(["dff", "z-score", "zscore", "z score", "output"]) + output_idx = _find_col([ + "dff", + "z-score", + "zscore", + "z score", + "output", + "raw_signal", + "raw_465", + "raw", + "isobestic", + "raw_405", + "reference", + "reference_405", + "ref", + "dio", + "baseline_465", + "baseline_405", + ]) has_header = time_idx is not None and output_idx is not None raw_idx = _find_col(["raw", "raw_465", "signal", "signal_465"]) if has_header else None @@ -1519,6 +4681,8 @@ def _find_col(names: List[str]) -> Optional[int]: col = "z-score" elif col == "dff": col = "dFF" + elif col == "raw_signal": + col = "Raw signal (465)" output_label = f"Imported CSV ({col})" return ProcessedTrial( @@ -1559,6 +4723,20 @@ def _load_processed_h5(self, path: str) -> Optional[ProcessedTrial]: out = np.asarray(g["z-score"][()], float) elif "zscore" in g: out = np.asarray(g["zscore"][()], float) + elif "raw_465" in g: + out = np.asarray(g["raw_465"][()], float) + elif "raw" in g: + out = np.asarray(g["raw"][()], float) + elif "raw_405" in g: + out = np.asarray(g["raw_405"][()], float) + elif "isobestic" in g: + out = np.asarray(g["isobestic"][()], float) + elif "dio" in g: + out = np.asarray(g["dio"][()], float) + elif "baseline_465" in g: + out = np.asarray(g["baseline_465"][()], float) + elif "baseline_405" in g: + out = np.asarray(g["baseline_405"][()], float) else: return None raw_sig = np.asarray(g["raw_465"][()], float) if "raw_465" in g else ( @@ -1598,16 +4776,154 @@ def _load_processed_h5(self, path: str) -> Optional[ProcessedTrial]: fs_used=fs_used, ) + def _processed_trial_to_loaded_doric(self, processed: ProcessedTrial) -> Optional[LoadedDoricFile]: + t = np.asarray(processed.time if processed.time is not None else np.array([], float), float) + if t.size < 2: + return None + + raw_sig = np.asarray( + processed.raw_signal if processed.raw_signal is not None else np.array([], float), + float, + ) + raw_ref = np.asarray( + processed.raw_reference if processed.raw_reference is not None else np.array([], float), + float, + ) + out = np.asarray(processed.output if processed.output is not None else np.array([], float), float) + + if raw_sig.size != t.size: + if out.size == t.size: + raw_sig = out.copy() + else: + return None + if raw_ref.size != t.size or not np.isfinite(raw_ref).any(): + # Keep preprocessing numerically stable even if original H5 has no raw_405. + raw_ref = raw_sig.copy() + + if not np.isfinite(raw_sig).any(): + return None + + channel = str(processed.channel_id or "AIN01").strip() or "AIN01" + dio_map: Dict[str, np.ndarray] = {} + dio_time_map: Dict[str, np.ndarray] = {} + digital_time: Optional[np.ndarray] = None + dio = np.asarray(processed.dio, float) if processed.dio is not None else np.array([], float) + if dio.size == t.size: + dio_name = str(processed.dio_name or "DIO_import").strip() or "DIO_import" + digital_time = t.copy() + dio_map[dio_name] = dio.copy() + dio_time_map[dio_name] = t.copy() + + return LoadedDoricFile( + path=str(processed.path or ""), + channels=[channel], + time_by_channel={channel: t.copy()}, + signal_by_channel={channel: raw_sig.copy()}, + reference_by_channel={channel: raw_ref.copy()}, + digital_time=digital_time, + digital_by_name={k: v.copy() for k, v in dio_map.items()}, + trigger_time_by_name={k: v.copy() for k, v in dio_time_map.items()}, + trigger_by_name={k: v.copy() for k, v in dio_map.items()}, + ) + + def _load_processed_h5_as_pre_file(self, path: str) -> Optional[LoadedDoricFile]: + processed = self._load_processed_h5(path) + if processed is None: + return None + loaded = self._processed_trial_to_loaded_doric(processed) + if loaded is None: + return None + loaded.path = path + return loaded + def closeEvent(self, event): + try: + self.post_tab.mark_app_closing() + except Exception: + pass + try: + current = self.tabs.currentWidget() if hasattr(self, "tabs") else None + if current is self.pre_tab: + # Closing on preprocessing: capture the live preprocessing dock topology. + self._store_pre_main_dock_snapshot() + else: + # Closing on postprocessing: preprocessing docks are hidden by tab switch. + self._persist_hidden_preprocessing_layout_state() + except Exception: + pass + try: + # Persist post layout from live state or cached tab-switch state without + # overwriting it with preprocessing topology. + self.post_tab.persist_layout_state_snapshot() + except Exception: + pass + try: + self.post_tab._on_about_to_quit() + except Exception: + pass + self._save_panel_layout_state() + self._save_panel_config_json() self._save_settings() + try: + self.settings.sync() + except Exception: + pass super().closeEvent(event) def main() -> None: - pg.setConfigOptions(antialias=True) + pg.setConfigOptions(antialias=False) + smoke_test = str(os.environ.get("PYBER_SMOKE_TEST", "")).strip().lower() in {"1", "true", "yes", "on"} app = QtWidgets.QApplication([]) + icon_path = _pyber_icon_path() + try: + if os.path.isfile(icon_path): + app_icon = QtGui.QIcon(icon_path) + if not app_icon.isNull(): + app.setWindowIcon(app_icon) + except Exception: + pass + splash = None + if not smoke_test: + try: + if os.path.isfile(icon_path): + pix = QtGui.QPixmap(icon_path) + if not pix.isNull(): + splash = QtWidgets.QSplashScreen(pix, QtCore.Qt.WindowType.WindowStaysOnTopHint) + splash.show() + app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + except Exception: + splash = None w = MainWindow() + + if smoke_test: + try: + w.show() + app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + if hasattr(w, "tabs") and hasattr(w, "post_tab"): + idx = w.tabs.indexOf(w.post_tab) + if idx >= 0: + w.tabs.setCurrentIndex(idx) + for _ in range(8): + app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + try: + if hasattr(w, "post_tab") and hasattr(w.post_tab, "ensure_section_popups_initialized"): + w.post_tab.ensure_section_popups_initialized() + except Exception: + pass + for _ in range(8): + app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + finally: + try: + w.close() + except Exception: + pass + app.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents) + return + w.show() + if splash is not None: + splash.finish(w) app.exec() diff --git a/pyBer/styles.py b/pyBer/styles.py index 8c61fcd..d570b89 100644 --- a/pyBer/styles.py +++ b/pyBer/styles.py @@ -1,169 +1,429 @@ # styles.py APP_QSS = r""" -/* Base */ -QWidget { - background: #0f141b; - color: #e6eefc; - font-family: Segoe UI, Arial, sans-serif; +/* Adobe-like dark palette */ +QMainWindow, QWidget { + background: #1f2229; + color: #f3f5f8; + font-family: "Segoe UI", "Arial", sans-serif; font-size: 9.5pt; } +QLabel { + color: #f3f5f8; +} + +QLabel[class="hint"] { + color: #aeb6c5; + font-size: 8.9pt; +} + +/* Panels */ QGroupBox { - border: 1px solid #1f2a38; - border-radius: 10px; + border: 1px solid #3a4050; + border-radius: 8px; margin-top: 10px; padding: 10px; - background: #0f141b; + background: #262b35; +} + +/* Keep helper row-container widgets inside group boxes transparent, so + row-spanning form rows do not appear as dark horizontal bands. */ +QGroupBox > QWidget { + background: transparent; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; - padding: 0 6px; - color: #cfe0ff; + padding: 0 7px; + color: #f3f5f8; font-weight: 700; } -/* Lists */ -QListWidget { - background: #0b1016; - border: 1px solid #1f2a38; - border-radius: 10px; - padding: 6px; +QFrame { + border-color: #3a4050; +} + +/* Lists and tables */ +QListWidget, QTableWidget { + background: #20242d; + border: 1px solid #3a4050; + border-radius: 7px; + padding: 4px; + gridline-color: #343a47; } -QListWidget::item:selected { - background: #1b3558; + +QListWidget::item, QTableWidget::item { + padding: 2px 4px; +} + +QListWidget::item:selected, QTableWidget::item:selected { + background: #1d4f80; color: #ffffff; - border-radius: 6px; + border-radius: 4px; +} + +QHeaderView::section { + background: #2b303b; + color: #ecf0f6; + border: 1px solid #3a4050; + padding: 4px 6px; + font-weight: 600; } /* Inputs */ QLineEdit, QAbstractSpinBox, QDoubleSpinBox, QSpinBox, QComboBox { - background: #0b1016; - color: #e6eefc; - border: 1px solid #26364a; - border-radius: 8px; - padding: 6px 8px; - selection-background-color: #2a66b3; + background: #1b2029; + color: #f3f5f8; + border: 1px solid #4a5163; + border-radius: 6px; + padding: 5px 8px; + selection-background-color: #2680eb; selection-color: #ffffff; + min-height: 20px; +} + +QLineEdit:focus, QAbstractSpinBox:focus, QComboBox:focus { + border: 1px solid #378ef0; } QComboBox::drop-down { - border-left: 1px solid #26364a; - width: 24px; + border-left: 1px solid #4a5163; + width: 20px; } + QComboBox QAbstractItemView { - background: #0b1016; - color: #e6eefc; - border: 1px solid #26364a; - selection-background-color: #1b3558; + background: #1f242e; + color: #f3f5f8; + border: 1px solid #4a5163; + selection-background-color: #1d4f80; +} + +QCheckBox { + spacing: 6px; +} + +QCheckBox::indicator { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid #5a6274; + background: #1b2029; +} + +QCheckBox::indicator:checked { + background: #2680eb; + border: 1px solid #378ef0; } /* Buttons */ QPushButton { - background: #162030; - border: 1px solid #2a3c55; - border-radius: 10px; - padding: 10px 12px; + background: #2b303b; + border: 1px solid #4a5163; + border-radius: 7px; + padding: 8px 12px; font-weight: 600; + color: #f3f5f8; } + QPushButton:hover { - background: #1b2a3f; + background: #343a48; + border: 1px solid #5b6480; } + QPushButton:pressed { - background: #122033; + background: #232833; +} + +QPushButton:disabled { + color: #858f9f; + background: #252a33; + border: 1px solid #373d4a; } QPushButton[class="compact"] { - padding: 6px 10px; - border-radius: 9px; + padding: 5px 10px; + border-radius: 7px; font-weight: 600; } + QPushButton[class="compactSmall"] { - padding: 2px 6px; - border-radius: 8px; + padding: 2px 7px; + border-radius: 6px; font-weight: 600; - font-size: 8.0pt; + font-size: 8.2pt; min-height: 18px; } + +QPushButton[class="compactPrimary"], +QPushButton[class="compactPrimarySmall"], +QPushButton[class="bluePrimarySmall"] { + background: #7d4df2; + border: 1px solid #9064ff; + color: #ffffff; + font-weight: 700; +} + QPushButton[class="compactPrimary"] { - padding: 6px 10px; - border-radius: 9px; - font-weight: 800; - background: #254a7a; - border: 1px solid #2f6bb3; -} -QPushButton[class="compactPrimarySmall"] { - padding: 2px 6px; - border-radius: 8px; - font-weight: 800; - font-size: 8.0pt; - background: #254a7a; - border: 1px solid #2f6bb3; + padding: 5px 10px; + border-radius: 7px; } -QPushButton[class="compactPrimarySmall"]::text { - text-align: left; + +QPushButton[class="compactPrimarySmall"], +QPushButton[class="bluePrimarySmall"] { + padding: 2px 7px; + border-radius: 6px; + font-size: 8.2pt; + min-height: 18px; +} + +QPushButton[class="compactPrimary"]:hover, +QPushButton[class="compactPrimarySmall"]:hover, +QPushButton[class="bluePrimarySmall"]:hover { + background: #8d60ff; + border: 1px solid #9f79ff; +} + +QPushButton[class="compactPrimary"]:pressed, +QPushButton[class="compactPrimarySmall"]:pressed, +QPushButton[class="bluePrimarySmall"]:pressed { + background: #6f3de9; + border: 1px solid #8253f3; +} + +QPushButton[class="blueSecondarySmall"] { + padding: 2px 7px; + border-radius: 6px; + font-weight: 600; + font-size: 8.2pt; + min-height: 18px; + background: #312a42; + border: 1px solid #5f4a89; + color: #eef2f7; +} + +QPushButton[class="blueSecondarySmall"]:hover { + background: #3b3251; + border: 1px solid #7059a0; +} + +QPushButton[class="blueSecondarySmall"]:pressed { + background: #2a2438; +} + +QPushButton[class="blueSecondarySmall"]:checked, +QPushButton[class="sectionButton"]:checked { + background: #7d4df2; + border: 1px solid #9064ff; + color: #ffffff; } -QPushButton[class="compactPrimarySmall"]:hover { - background: #2b5a96; + +QPushButton[class="sectionButton"] { + padding: 3px 9px; + border-radius: 6px; + font-weight: 600; + font-size: 8.4pt; + background: #2b303b; + border: 1px solid #4a5163; + text-align: left; } -QPushButton[class="compactPrimary"]:hover { - background: #2b5a96; + +QPushButton[class="sectionButton"]:hover { + background: #343a48; } + QPushButton[class="help"] { padding: 0; - border-radius: 11px; - min-width: 22px; - max-width: 22px; - min-height: 22px; - max-height: 22px; - font-weight: 800; - background: #1b2a3f; - border: 1px solid #2a3c55; + border-radius: 10px; + min-width: 20px; + max-width: 20px; + min-height: 20px; + max-height: 20px; + font-weight: 700; + background: #2f3543; + border: 1px solid #4d5568; } /* Tabs */ QTabWidget::pane { - border: 1px solid #1f2a38; - border-radius: 10px; + border: 1px solid #363c4a; + border-radius: 8px; + background: #1f2229; padding: 2px; } + QTabBar::tab { - background: #121a25; - border: 1px solid #1f2a38; + background: #2b303b; + border: 1px solid #3b4150; border-bottom: none; - padding: 10px 14px; - border-top-left-radius: 10px; - border-top-right-radius: 10px; + padding: 8px 12px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; margin-right: 2px; - font-weight: 700; - color: #b8c9e8; + font-weight: 600; + color: #bcc5d6; +} + +QTabBar::tab:hover { + background: #343a48; } + QTabBar::tab:selected { - background: #0f141b; + background: #1f2229; color: #ffffff; + border-color: #4a5163; } -/* Scroll */ -QScrollArea { - border: none; +/* Menus and tool buttons */ +QMenu { + background: #2b303b; + color: #f3f5f8; + border: 1px solid #41495a; +} + +QMenu::item { + padding: 6px 16px; +} + +QMenu::item:selected { + background: #1d4f80; +} + +QToolButton { + background: #2b303b; + border: 1px solid #4a5163; + border-radius: 6px; + padding: 3px 7px; +} + +QToolButton:hover { + background: #343a48; } -/* Dock */ +/* Docking and splitters */ QDockWidget { - background: #0f141b; - color: #e6eefc; + background: #242a34; + color: #f3f5f8; + border: 1px solid #3b4150; } + QDockWidget::title { - background: #121a25; - padding: 6px; + background: #2d3340; + border-bottom: 1px solid #3b4150; + padding: 6px 8px; + text-align: left; font-weight: 700; } -/* Hints/log */ -QLabel[class="hint"] { - color: #aab4c4; - font-size: 9.0pt; +QDockWidget > QWidget { + background: #242a34; +} + +QMainWindow::separator { + background: #3f4758; + width: 2px; + height: 2px; +} + +QMainWindow::separator:hover { + background: #2680eb; +} + +QSplitter::handle { + background: #3f4758; +} + +QSplitter::handle:hover { + background: #2680eb; +} + +QScrollArea { + border: none; +} + +/* Plot frame support */ +QGraphicsView { + background: #121722; + border: 1px solid #313746; + border-radius: 6px; +} + +QToolTip { + background: #2d3442; + color: #f3f5f8; + border: 1px solid #4a5163; + padding: 4px 6px; } """ + + +_LIGHT_COLOR_MAP = { + "#121722": "#ffffff", + "#1473e6": "#1f6fce", + "#1b2029": "#ffffff", + "#1d4f80": "#2f7fd8", + "#1f2229": "#f4f6fb", + "#1f242e": "#ffffff", + "#20242d": "#ffffff", + "#232833": "#d7dfeb", + "#242a34": "#f6f8fc", + "#252a33": "#eef2f8", + "#262b35": "#ffffff", + "#2680eb": "#2f7fd8", + "#2b303b": "#e8eef6", + "#2d3340": "#e1e8f2", + "#2d3442": "#f3f6fb", + "#2f3543": "#eef3fa", + "#313746": "#c8d2e0", + "#343a47": "#d2dae6", + "#343a48": "#dde6f1", + "#363c4a": "#cad3e1", + "#373d4a": "#d4dce8", + "#378ef0": "#2f7fd8", + "#3a4050": "#c2ccda", + "#3b4150": "#c5cedc", + "#3f4758": "#bcc8d8", + "#41495a": "#bcc8da", + "#4a5163": "#adb8c9", + "#4b9df8": "#5fa8f5", + "#4d5568": "#aab6c8", + "#5a6274": "#95a3b7", + "#5b6480": "#9eadc4", + "#5f4a89": "#b9a9df", + "#6f3de9": "#8f69d9", + "#7059a0": "#b8a8da", + "#7d4df2": "#8f69d9", + "#8253f3": "#9d7fe0", + "#858f9f": "#8c98ab", + "#8d60ff": "#9d7fe0", + "#9064ff": "#9d7fe0", + "#9f79ff": "#b09ae8", + "#aeb6c5": "#5d6a7d", + "#2a2438": "#ebe4fa", + "#312a42": "#e7e0f8", + "#3b3251": "#ddd3f3", + "#bcc5d6": "#667489", + "#ecf0f6": "#243247", + "#eef2f7": "#1f2a37", + "#f3f5f8": "#1f2a37", + "#ffffff": "#ffffff", +} + + +def _build_light_qss(dark_qss: str) -> str: + light = str(dark_qss) + # Replace longer tokens first to avoid accidental partial replacement collisions. + for dark, bright in sorted(_LIGHT_COLOR_MAP.items(), key=lambda kv: len(kv[0]), reverse=True): + light = light.replace(dark, bright) + return light + + +APP_QSS_LIGHT = _build_light_qss(APP_QSS) + + +def app_qss(theme_mode: object) -> str: + mode = str(theme_mode or "").strip().lower() + if mode in {"light", "white", "l", "w"}: + return APP_QSS_LIGHT + return APP_QSS