diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 701b674f..fee0e626 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -110,11 +110,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
# Push core test coverage badge endpoint.json to the data branch so the
- # README badge resolves on every branch, not only after merging to main.
- - name: Remove .coverage database before Codecov upload
- if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
- run: rm -f .coverage # prevent codecov CLI from regenerating coverage.xml with wrong paths
-
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
uses: codecov/codecov-action@v5
diff --git a/README.md b/README.md
index d1c641f2..536c473c 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,8 @@ Full documentation: [synaptipy.readthedocs.io](https://synaptipy.readthedocs.io/
## Installation
+**WARNING: PySide6 must remain pinned to version 6.7.3 due to QTBUG-130070.**
+
### Prerequisites
- [Anaconda](https://www.anaconda.com/download) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html)
@@ -57,7 +59,7 @@ synaptipy
python -m Synaptipy
```
-
+
Load a recording by dragging a file into the **Explorer** tab, then navigate to the **Analyser** tab to select a channel and run an analysis. Results are displayed in a table and can be exported to CSV.
@@ -259,42 +261,42 @@ are listed below; see the [full references page](https://synaptipy.readthedocs.i
in the documentation for a complete annotated bibliography.
**Action potential detection and kinetics:**
-- Bean BP (2007). The action potential in mammalian central neurons. *Nat Rev Neurosci* 8:451-465. [doi:10.1038/nrn2148](https://doi.org/10.1038/nrn2148) — dV/dt threshold default (20 V/s)
-- Hodgkin AL & Huxley AF (1952). *J Physiol* 117:500-544. [doi:10.1113/jphysiol.1952.sp004764](https://doi.org/10.1113/jphysiol.1952.sp004764) — foundational AP model
-- Sekerli M et al. (2004). *IEEE Trans Biomed Eng* 51:1665-1672. [doi:10.1109/TBME.2004.827827](https://doi.org/10.1109/TBME.2004.827827) — maximum-curvature threshold method
-- Naundorf B et al. (2006). *Nature* 440:1060-1063. [doi:10.1038/nature04610](https://doi.org/10.1038/nature04610) — artifact ceiling (300 V/s)
+- Bean BP (2007). The action potential in mammalian central neurons. *Nat Rev Neurosci* 8:451-465. [doi:10.1038/nrn2148](https://doi.org/10.1038/nrn2148) - dV/dt threshold default (20 V/s)
+- Hodgkin AL & Huxley AF (1952). *J Physiol* 117:500-544. [doi:10.1113/jphysiol.1952.sp004764](https://doi.org/10.1113/jphysiol.1952.sp004764) - foundational AP model
+- Sekerli M et al. (2004). *IEEE Trans Biomed Eng* 51:1665-1672. [doi:10.1109/TBME.2004.827827](https://doi.org/10.1109/TBME.2004.827827) - maximum-curvature threshold method
+- Naundorf B et al. (2006). *Nature* 440:1060-1063. [doi:10.1038/nature04610](https://doi.org/10.1038/nature04610) - artifact ceiling (300 V/s)
**Passive membrane properties:**
-- Hamill OP et al. (1981). *Pflugers Arch* 391:85-100. [doi:10.1007/BF00656997](https://doi.org/10.1007/BF00656997) — patch-clamp; series resistance and capacitance
-- Robinson RB & Siegelbaum SA (2003). *Annu Rev Physiol* 65:453-480. [doi:10.1146/annurev.physiol.65.092101.142734](https://doi.org/10.1146/annurev.physiol.65.092101.142734) — HCN / Ih current; peak vs. steady-state Rᵢₙ
+- Hamill OP et al. (1981). *Pflugers Arch* 391:85-100. [doi:10.1007/BF00656997](https://doi.org/10.1007/BF00656997) - patch-clamp; series resistance and capacitance
+- Robinson RB & Siegelbaum SA (2003). *Annu Rev Physiol* 65:453-480. [doi:10.1146/annurev.physiol.65.092101.142734](https://doi.org/10.1146/annurev.physiol.65.092101.142734) - HCN / Ih current; peak vs. steady-state Rᵢₙ
**After-hyperpolarisation:**
-- Storm JF (1987). *J Physiol* 385:733-759. [doi:10.1113/jphysiol.1987.sp016517](https://doi.org/10.1113/jphysiol.1987.sp016517) — fast AHP (BK, 1-5 ms)
-- Sah P & Faber ESL (2002). *Prog Neurobiol* 66:345-353. [doi:10.1016/S0301-0082(02)00004-7](https://doi.org/10.1016/S0301-0082(02)00004-7) — medium AHP (SK, 10-50 ms)
+- Storm JF (1987). *J Physiol* 385:733-759. [doi:10.1113/jphysiol.1987.sp016517](https://doi.org/10.1113/jphysiol.1987.sp016517) - fast AHP (BK, 1-5 ms)
+- Sah P & Faber ESL (2002). *Prog Neurobiol* 66:345-353. [doi:10.1016/S0301-0082(02)00004-7](https://doi.org/10.1016/S0301-0082(02)00004-7) - medium AHP (SK, 10-50 ms)
**Spike-train statistics:**
-- Holt GR et al. (1996). *J Neurophysiol* 75:1806-1814. [doi:10.1152/jn.1996.75.5.1806](https://doi.org/10.1152/jn.1996.75.5.1806) — CV and CV₂
-- Shinomoto S et al. (2003). *Neural Comput* 15:2823-2842. [doi:10.1162/089976603322518759](https://doi.org/10.1162/089976603322518759) — Local Variation (LV)
+- Holt GR et al. (1996). *J Neurophysiol* 75:1806-1814. [doi:10.1152/jn.1996.75.5.1806](https://doi.org/10.1152/jn.1996.75.5.1806) - CV and CV₂
+- Shinomoto S et al. (2003). *Neural Comput* 15:2823-2842. [doi:10.1162/089976603322518759](https://doi.org/10.1162/089976603322518759) - Local Variation (LV)
**Burst detection:**
-- Grace AA & Bunney BS (1984). *J Neurosci* 4:2877-2890. [doi:10.1523/JNEUROSCI.04-11-02877.1984](https://doi.org/10.1523/JNEUROSCI.04-11-02877.1984) — ISI burst criterion
-- Harris KD et al. (2001). *Neuron* 32:141-149. [doi:10.1016/S0896-6273(01)00447-0](https://doi.org/10.1016/S0896-6273(01)00447-0) — dynamic ISI fraction (30%)
+- Grace AA & Bunney BS (1984). *J Neurosci* 4:2877-2890. [doi:10.1523/JNEUROSCI.04-11-02877.1984](https://doi.org/10.1523/JNEUROSCI.04-11-02877.1984) - ISI burst criterion
+- Harris KD et al. (2001). *Neuron* 32:141-149. [doi:10.1016/S0896-6273(01)00447-0](https://doi.org/10.1016/S0896-6273(01)00447-0) - dynamic ISI fraction (30%)
**Synaptic event detection:**
-- Rall W (1967). *J Neurophysiol* 30:1138-1168. [doi:10.1152/jn.1967.30.5.1138](https://doi.org/10.1152/jn.1967.30.5.1138) — cable theory; dendritic filtering (2-3x tau)
-- Hampel FR (1974). *J Am Stat Assoc* 69:383-393. [doi:10.1080/01621459.1974.10482962](https://doi.org/10.1080/01621459.1974.10482962) — MAD noise estimator (1.4826 factor)
+- Rall W (1967). *J Neurophysiol* 30:1138-1168. [doi:10.1152/jn.1967.30.5.1138](https://doi.org/10.1152/jn.1967.30.5.1138) - cable theory; dendritic filtering (2-3x tau)
+- Hampel FR (1974). *J Am Stat Assoc* 69:383-393. [doi:10.1080/01621459.1974.10482962](https://doi.org/10.1080/01621459.1974.10482962) - MAD noise estimator (1.4826 factor)
**Paired-pulse ratio:**
-- Zucker RS & Regehr WG (2002). *Annu Rev Physiol* 64:355-405. [doi:10.1146/annurev.physiol.64.092501.114547](https://doi.org/10.1146/annurev.physiol.64.092501.114547) — short-term synaptic plasticity
-- Regehr WG (2012). *Cold Spring Harb Perspect Biol* 4:a005702. [doi:10.1101/cshperspect.a005702](https://doi.org/10.1101/cshperspect.a005702) — PPR facilitation/depression classification
+- Zucker RS & Regehr WG (2002). *Annu Rev Physiol* 64:355-405. [doi:10.1146/annurev.physiol.64.092501.114547](https://doi.org/10.1146/annurev.physiol.64.092501.114547) - short-term synaptic plasticity
+- Regehr WG (2012). *Cold Spring Harb Perspect Biol* 4:a005702. [doi:10.1101/cshperspect.a005702](https://doi.org/10.1101/cshperspect.a005702) - PPR facilitation/depression classification
**Signal filtering:**
-- Savitzky A & Golay MJE (1964). *Anal Chem* 36:1627-1639. [doi:10.1021/ac60214a047](https://doi.org/10.1021/ac60214a047) — Savitzky-Golay smoothing
-- Welch PD (1967). *IEEE Trans Audio Electroacoust* 15:70-73. [doi:10.1109/TAU.1967.1161901](https://doi.org/10.1109/TAU.1967.1161901) — Welch PSD / line noise detection
+- Savitzky A & Golay MJE (1964). *Anal Chem* 36:1627-1639. [doi:10.1021/ac60214a047](https://doi.org/10.1021/ac60214a047) - Savitzky-Golay smoothing
+- Welch PD (1967). *IEEE Trans Audio Electroacoust* 15:70-73. [doi:10.1109/TAU.1967.1161901](https://doi.org/10.1109/TAU.1967.1161901) - Welch PSD / line noise detection
**Electrode corrections:**
-- Barry PH & Lynch JW (1991). *J Membr Biol* 121:101-117. [doi:10.1007/BF01870526](https://doi.org/10.1007/BF01870526) — liquid junction potential correction
-- Armstrong CM & Bezanilla F (1977). *J Gen Physiol* 70:567-590. [doi:10.1085/jgp.70.5.567](https://doi.org/10.1085/jgp.70.5.567) — P/N leak subtraction protocol
+- Barry PH & Lynch JW (1991). *J Membr Biol* 121:101-117. [doi:10.1007/BF01870526](https://doi.org/10.1007/BF01870526) - liquid junction potential correction
+- Armstrong CM & Bezanilla F (1977). *J Gen Physiol* 70:567-590. [doi:10.1085/jgp.70.5.567](https://doi.org/10.1085/jgp.70.5.567) - P/N leak subtraction protocol
---
diff --git a/check_docs.py b/check_docs.py
new file mode 100644
index 00000000..f102bc89
--- /dev/null
+++ b/check_docs.py
@@ -0,0 +1,25 @@
+import ast
+import os
+import glob
+from collections import defaultdict
+
+def check_file(filepath):
+ with open(filepath, 'r') as f:
+ content = f.read()
+
+ tree = ast.parse(content)
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef):
+ args = [arg.arg for arg in node.args.args if arg.arg not in ('self', 'cls')]
+ args += [arg.arg for arg in node.args.kwonlyargs]
+
+ docstring = ast.get_docstring(node)
+ if docstring:
+ for arg in args:
+ # basic check: is the arg mentioned in the docstring?
+ # A more rigorous check parses the Google/Numpy style.
+ # We will just look for `arg:` or `arg ` or `*arg*` etc.
+ # Actually, if we use a regex:
+ pass
+
diff --git a/clean_slop.py b/clean_slop.py
new file mode 100644
index 00000000..24a778d0
--- /dev/null
+++ b/clean_slop.py
@@ -0,0 +1,52 @@
+import re
+import os
+
+FILES = [
+ "README.md",
+ "docs/user_guide.md",
+ "docs/developer_guide.md",
+ "docs/algorithmic_definitions.md"
+]
+
+def clean_slop(text):
+ # It is important to note that -> remove and capitalize
+ def repl_important(m):
+ next_char = m.group(1)
+ return next_char.upper()
+ text = re.sub(r'(?i)It is important to note that\s+([a-z])', repl_important, text)
+ text = re.sub(r'(?i)It is important to note that\s*', '', text)
+
+ # Seamlessly integrates -> Integrates
+ text = re.sub(r'(?i)Seamlessly integrates', 'Integrates', text)
+ text = re.sub(r'(?i)seamlessly integrates', 'integrates', text)
+
+ # Empowers users to -> Allows users to
+ text = re.sub(r'(?i)Empowers users to', 'Allows users to', text)
+ text = re.sub(r'(?i)empowers users to', 'allows users to', text)
+
+ # In conclusion, -> remove
+ def repl_conclusion(m):
+ next_char = m.group(1)
+ return next_char.upper()
+ text = re.sub(r'(?i)In conclusion,\s+([a-z])', repl_conclusion, text)
+ text = re.sub(r'(?i)In conclusion,\s*', '', text)
+
+ # Replace em-dashes
+ # usually surrounded by spaces: ` — ` -> ` - `
+ text = text.replace(' — ', ' - ')
+ # sometimes not: `word—word` -> `word - word`
+ text = text.replace('—', ' - ')
+
+ return text
+
+for fpath in FILES:
+ if os.path.exists(fpath):
+ with open(fpath, "r") as f:
+ content = f.read()
+ new_content = clean_slop(content)
+ if content != new_content:
+ with open(fpath, "w") as f:
+ f.write(new_content)
+ print(f"Cleaned {fpath}")
+ else:
+ print(f"File not found: {fpath}")
diff --git a/doc_check.txt b/doc_check.txt
new file mode 100644
index 00000000..dcf6ce0a
--- /dev/null
+++ b/doc_check.txt
@@ -0,0 +1,291 @@
+src/Synaptipy/core/analysis/synaptic_events.py::find_quiescent_baseline_rms:
+ In doc, not in sig: {'variance', 'Unlike', 'Tuple', 'window', 'considers', 'define', 'robust', 'Identify'}
+src/Synaptipy/core/analysis/synaptic_events.py::calculate_event_charge_dynamic:
+ In doc, not in sig: {'Signed', 'The', 'summating', 'Integrate', 'early'}
+src/Synaptipy/core/analysis/synaptic_events.py::fit_biexponential_decay:
+ In doc, not in sig: {'with', 'Dict', 'Tries', 'string', 'amplitudes', 'Fit'}
+src/Synaptipy/core/analysis/synaptic_events.py::compute_local_pre_event_baseline:
+ In doc, not in sig: {'peak', 'the', 'Compute', 'window', 'and', 'searches', 'begins', 'For'}
+src/Synaptipy/core/analysis/synaptic_events.py::_fit_p1_decay_residual:
+ In doc, not in sig: {'more', 'the', 'Tuple', 'back', 'Fit'}
+ In sig, not in doc: {'sample_rate', 'global_baseline', 'data', 'peak1_idx', 's2_t', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::_measure_ppr_peak:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'baseline', 'resp_samples', 'data', 'onset_s', 'polarity', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::calculate_paired_pulse_ratio:
+ In doc, not in sig: {'baseline', 'Keys', 'Sampling', 'tail', 'Duration', 'Calculate', 'PPR'}
+src/Synaptipy/core/analysis/synaptic_events.py::detect_events_threshold:
+ In doc, not in sig: {'Detect', 'noise', 'activity'}
+ In sig, not in doc: {'rolling_baseline_window_ms', 'threshold', 'refractory_period', 'data', 'quiescent_window_ms', 'polarity', 'use_quiescent_noise_floor', 'artifact_mask', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::run_event_detection_threshold_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::detect_events_template:
+ In doc, not in sig: {'Detect', 'for', 'somatic', 'filtering', 'filtered', 'Kernels'}
+ In sig, not in doc: {'kernel_shape', 'rolling_baseline_window_ms', 'tau_rise', 'data', 'kernel_multipliers', 'tau_decay', 'sampling_rate', 'polarity', 'threshold_std', 'min_event_distance_ms', 'artifact_mask', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::run_event_detection_template_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::_find_stable_baseline_segment:
+ In doc, not in sig: {'Find'}
+ In sig, not in doc: {'sample_rate', 'data', 'window_duration_s', 'step_duration_s'}
+src/Synaptipy/core/analysis/synaptic_events.py::detect_events_baseline_peak_kinetics:
+ In doc, not in sig: {'Minimum', 'Raw', 'Detect', 'Step', 'Window', 'Whether', 'Sampling', 'Custom', 'Detection'}
+src/Synaptipy/core/analysis/synaptic_events.py::run_event_detection_baseline_peak_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/synaptic_events.py::_build_kernel:
+ In doc, not in sig: {'Build'}
+ In sig, not in doc: {'td'}
+src/Synaptipy/core/analysis/registry.py::register:
+ In doc, not in sig: {'documentation', 'return', 'this', 'This', 'when', 'requires', 'def', 'expects_list', 'engine', 'The', 'from', 'Decorator', 'synaptic', 'NumPy'}
+src/Synaptipy/core/analysis/registry.py::register_processor:
+ In doc, not in sig: {'Decorator', 'Alias'}
+ In sig, not in doc: {'name'}
+src/Synaptipy/core/analysis/registry.py::get_function:
+ In doc, not in sig: {'Retrieve', 'The'}
+src/Synaptipy/core/analysis/registry.py::get_metadata:
+ In doc, not in sig: {'Retrieve', 'Dictionary'}
+src/Synaptipy/core/analysis/registry.py::list_registered:
+ In doc, not in sig: {'Get', 'List'}
+src/Synaptipy/core/analysis/registry.py::list_by_type:
+ In doc, not in sig: {'Get', 'List'}
+src/Synaptipy/core/analysis/registry.py::list_preprocessing:
+ In doc, not in sig: {'Get'}
+src/Synaptipy/core/analysis/registry.py::list_analysis:
+ In doc, not in sig: {'Get'}
+src/Synaptipy/core/analysis/registry.py::mark_core_snapshot:
+ In doc, not in sig: {'Call', 'Record', 'uses'}
+src/Synaptipy/core/analysis/registry.py::unregister_plugins:
+ In doc, not in sig: {'Safe', 'Remove', 'last'}
+src/Synaptipy/core/analysis/registry.py::clear:
+ In doc, not in sig: {'Clear'}
+src/Synaptipy/core/analysis/registry.py::update_default_params:
+ In doc, not in sig: {'Update'}
+src/Synaptipy/core/analysis/registry.py::reset_to_factory:
+ In doc, not in sig: {'Reset'}
+ In sig, not in doc: {'analysis_name'}
+src/Synaptipy/core/analysis/passive_properties.py::apply_ljp_correction:
+ In doc, not in sig: {'unnecessary', 'Corrected', 'Return', 'When'}
+src/Synaptipy/core/analysis/passive_properties.py::_sag_nan_payload:
+ In doc, not in sig: {'Return'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_rmp:
+ In doc, not in sig: {'samples', 'baseline', 'Attributes', 'Calculate', 'fits', 'length'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_baseline_stats:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'start_time', 'voltage', 'time', 'end_time'}
+src/Synaptipy/core/analysis/passive_properties.py::find_stable_baseline:
+ In doc, not in sig: {'Find'}
+ In sig, not in doc: {'sample_rate', 'data', 'window_duration_s', 'step_duration_s'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_rin:
+ In doc, not in sig: {'Three', 'the', 'negative', 'Attributes', 'Arbitrary', 'where', 'window', 'from', 'Amplitude', 'Duration', 'The', 'Calculate', 'Uses', 'samples', 'excluded'}
+src/Synaptipy/core/analysis/passive_properties.py::_fit_vc_transient_decay:
+ In doc, not in sig: {'Fit'}
+ In sig, not in doc: {'decay_segment', 't_decay', 'i_peak', 'cm_charge_pf', 'rs_mohm'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_vc_transient_parameters:
+ In doc, not in sig: {'Time', 'Keys', 'Amplitude', 'Duration', 'tau_c', 'All', 'whose', 'Fit', 'and'}
+src/Synaptipy/core/analysis/passive_properties.py::_extrapolate_rs_at_t0:
+ In doc, not in sig: {'Extrapolates', 'Fit', 'instantaneous', 'Falls'}
+ In sig, not in doc: {'t_art', 'artifact_window_ms', 'v_art'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_cc_series_resistance_fast:
+ In doc, not in sig: {'Time', 'the', 'Estimate', 'Keys', 'series', 'Membrane', 'derived', 'Amplitude', 'Duration', 'When', 'Values', 'Input', 'derive'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_conductance:
+ In doc, not in sig: {'Calculate'}
+ In sig, not in doc: {'parameters', 'baseline_window', 'time_vector', 'voltage_step', 'response_window', 'current_trace'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_iv_curve:
+ In doc, not in sig: {'Calculate'}
+ In sig, not in doc: {'time_vectors', 'sweeps', 'baseline_window', 'response_window', 'current_steps'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_tau:
+ In doc, not in sig: {'avoiding', 'dict', 'hyperpolarising', 'values', 'hippocampal', 'prevent', 'amplitude', 'Exponential', 'Onset', 'most', 'window', 'second', 'Duration', 'Calculate', 'applied', 'and', 'capacitive', 'For'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_sag_ratio:
+ In doc, not in sig: {'Calculate'}
+ In sig, not in doc: {'baseline_window', 'time_vector', 'peak_smoothing_ms', 'response_peak_window', 'voltage_trace', 'rebound_window_ms', 'response_steady_state_window'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_capacitance_cc:
+ In doc, not in sig: {'input', 'the', 'approximation', 'Membrane', 'Without', 'When', 'Calculate'}
+src/Synaptipy/core/analysis/passive_properties.py::_fit_cm_from_transient:
+ In doc, not in sig: {'Fit'}
+ In sig, not in doc: {'t_trans', 't_decay', 'rs_mohm', 'voltage_step_amplitude_mv', 'delta_i', 'i_decay'}
+src/Synaptipy/core/analysis/passive_properties.py::calculate_capacitance_vc:
+ In doc, not in sig: {'exponential', 'Dict', 'Method', 'Falls', 'Calculate'}
+src/Synaptipy/core/analysis/passive_properties.py::_coerce_trial_lists:
+ In doc, not in sig: {'Normalise'}
+ In sig, not in doc: {'data_list', 'time_list'}
+src/Synaptipy/core/analysis/passive_properties.py::_resolve_sweep_baseline:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'window_duration', 'sweep_time', 'baseline_start', 'step_duration', 'auto_detect', 'sampling_rate', 'rs_artifact_blanking_ms', 'sweep_data', 'baseline_end'}
+src/Synaptipy/core/analysis/passive_properties.py::run_rmp_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'data_list', 'sampling_rate', 'time_list'}
+src/Synaptipy/core/analysis/passive_properties.py::run_sag_ratio_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/passive_properties.py::run_rin_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/passive_properties.py::run_tau_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/passive_properties.py::run_iv_curve_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'data_list', 'sampling_rate', 'time_list'}
+src/Synaptipy/core/analysis/passive_properties.py::run_capacitance_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/firing_dynamics.py::calculate_fi_curve:
+ In doc, not in sig: {'Calculate', 'Dictionary'}
+src/Synaptipy/core/analysis/firing_dynamics.py::run_excitability_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'data_list', 'sampling_rate', 'time_list'}
+src/Synaptipy/core/analysis/firing_dynamics.py::calculate_bursts_logic:
+ In doc, not in sig: {'This', 'Detect', 'BurstResult', 'define', 'temporal'}
+ In sig, not in doc: {'parameters'}
+src/Synaptipy/core/analysis/firing_dynamics.py::analyze_spikes_and_bursts:
+ In doc, not in sig: {'Detect'}
+ In sig, not in doc: {'max_isi_start', 'parameters', 'burst_isi_fraction', 'threshold', 'dynamic_burst', 'data', 'sampling_rate', 'refractory_ms', 'max_isi_end', 'time'}
+src/Synaptipy/core/analysis/firing_dynamics.py::run_burst_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/firing_dynamics.py::calculate_train_dynamics:
+ In doc, not in sig: {'Compute'}
+src/Synaptipy/core/analysis/firing_dynamics.py::run_train_dynamics_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/batch_engine.py::_worker_process_file:
+ In doc, not in sig: {'This', 'List', 'OOM', 'Process', 'recursive', 'that'}
+src/Synaptipy/core/analysis/batch_engine.py::__init__:
+ In doc, not in sig: {'Initialize', 'Values'}
+src/Synaptipy/core/analysis/batch_engine.py::cancel:
+ In doc, not in sig: {'Request'}
+src/Synaptipy/core/analysis/batch_engine.py::update_performance_settings:
+ In doc, not in sig: {'Dynamically', 'This', 'immediately', 'Reads'}
+src/Synaptipy/core/analysis/batch_engine.py::list_available_analyses:
+ In doc, not in sig: {'Get', 'List'}
+src/Synaptipy/core/analysis/batch_engine.py::get_analysis_info:
+ In doc, not in sig: {'Get', 'Dictionary'}
+src/Synaptipy/core/analysis/batch_engine.py::_sanitise_value:
+ In doc, not in sig: {'Tuple', 'Sanitise'}
+ In sig, not in doc: {'key', 'value'}
+src/Synaptipy/core/analysis/batch_engine.py::_sanitise_ndarray:
+ In doc, not in sig: {'Summarise'}
+ In sig, not in doc: {'key', 'value'}
+src/Synaptipy/core/analysis/batch_engine.py::_sanitise_long_list:
+ In doc, not in sig: {'Summarise'}
+ In sig, not in doc: {'key', 'value'}
+src/Synaptipy/core/analysis/batch_engine.py::_sanitise_result_for_export:
+ In doc, not in sig: {'Cleaned', 'Make', 'tables', 'under', 'object', 'both'}
+ In sig, not in doc: {'result'}
+src/Synaptipy/core/analysis/batch_engine.py::_recording_metadata:
+ In doc, not in sig: {'Includes', 'that', 'Extract'}
+ In sig, not in doc: {'recording'}
+src/Synaptipy/core/analysis/batch_engine.py::_order_columns:
+ In doc, not in sig: {'Reorder'}
+ In sig, not in doc: {'df'}
+src/Synaptipy/core/analysis/batch_engine.py::_append_batch_error_log:
+ In doc, not in sig: {'Append', 'Writing', 'aborted'}
+src/Synaptipy/core/analysis/batch_engine.py::_run_batch_parallel:
+ In doc, not in sig: {'Distribute', 'are', 'through', 'OOM', 'Each'}
+ In sig, not in doc: {'files', 'pipeline_config', 'channel_filter', 'progress_callback'}
+src/Synaptipy/core/analysis/batch_engine.py::run_batch:
+ In doc, not in sig: {'blocked', 'pandas', 'Run', 'When'}
+ In sig, not in doc: {'cross_file_average'}
+src/Synaptipy/core/analysis/batch_engine.py::_run_cross_file_average:
+ In doc, not in sig: {'with', 'then', 'Aggregate', 'The'}
+ In sig, not in doc: {'files', 'pipeline_config', 'channel_filter', 'progress_callback'}
+src/Synaptipy/core/analysis/batch_engine.py::_run_batch_sequential:
+ In doc, not in sig: {'Sequential'}
+ In sig, not in doc: {'files', 'progress_callback', 'rs_tolerance', 'channel_filter', 'pipeline_config'}
+src/Synaptipy/core/analysis/batch_engine.py::_process_task:
+ In doc, not in sig: {'Tuple', 'Process'}
+src/Synaptipy/core/analysis/epoch_manager.py::duration:
+ In doc, not in sig: {'Epoch'}
+src/Synaptipy/core/analysis/epoch_manager.py::contains:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'t'}
+src/Synaptipy/core/analysis/epoch_manager.py::epochs:
+ In doc, not in sig: {'Sorted'}
+src/Synaptipy/core/analysis/epoch_manager.py::epoch_names:
+ In doc, not in sig: {'Names'}
+src/Synaptipy/core/analysis/epoch_manager.py::add_manual_epoch:
+ In doc, not in sig: {'Add', 'ValueError', 'The'}
+src/Synaptipy/core/analysis/epoch_manager.py::from_ttl:
+ In doc, not in sig: {'Detects', 'then', 'List'}
+ In sig, not in doc: {'post_stim_s', 'ttl_threshold', 'ttl_data', 'min_inter_epoch_s', 'pre_stim_s', 'stim_name', 'washout_name', 'time', 'baseline_name'}
+src/Synaptipy/core/analysis/epoch_manager.py::get_epoch:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'name'}
+src/Synaptipy/core/analysis/epoch_manager.py::epochs_at_time:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'t'}
+src/Synaptipy/core/analysis/epoch_manager.py::get_epoch_slices:
+ In doc, not in sig: {'Dict', 'Epochs', 'Extract'}
+src/Synaptipy/core/analysis/epoch_manager.py::remove_epoch:
+ In doc, not in sig: {'Remove'}
+ In sig, not in doc: {'name'}
+src/Synaptipy/core/analysis/epoch_manager.py::clear:
+ In doc, not in sig: {'Remove'}
+src/Synaptipy/core/analysis/cross_file_utils.py::_resolve_effective_trials:
+ In doc, not in sig: {'Return', 'items'}
+ In sig, not in doc: {'parsed_trials', 'item', 'channel'}
+src/Synaptipy/core/analysis/cross_file_utils.py::extract_per_file_trace:
+ In doc, not in sig: {'handle', 'across', 'contribute', 'Files', 'are', 'Load'}
+src/Synaptipy/core/analysis/cross_file_utils.py::get_cross_file_average:
+ In doc, not in sig: {'different', 'with', 'own', 'Ordered', 'shared', 'decreases', 'Delegates', 'where', 'Compute', 'scientifically', 'Adapter', 'whose', 'that'}
+src/Synaptipy/core/analysis/cross_file_utils.py::build_averaged_recording:
+ In doc, not in sig: {'calls', 'path', 'all', 'obtained', 'Recording', 'Short', 'Build', 'Populated', 'Adapter', 'For'}
+src/Synaptipy/core/analysis/cross_file_utils.py::_make_mfa_label:
+ In doc, not in sig: {'replaced', 'Derive', 'Takes', 'Iterable'}
+src/Synaptipy/core/analysis/cross_file_utils.py::average_padded_trials:
+ In doc, not in sig: {'artificial', 'Shorter', 'Compute', 'all', 'produces', 'Flat'}
+src/Synaptipy/core/analysis/single_spike.py::detect_spikes_threshold:
+ In doc, not in sig: {'Minimum', 'Number', 'Detect', 'first', 'Attributes', 'Converted', 'spike', 'Arbitrary', 'Convert', 'voltage', 'crossing', 'within', 'fewer', 'upward'}
+src/Synaptipy/core/analysis/single_spike.py::calculate_spike_features:
+ In doc, not in sig: {'Calculate', 'window', 'above'}
+src/Synaptipy/core/analysis/single_spike.py::calculate_isi:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'spike_times'}
+src/Synaptipy/core/analysis/single_spike.py::analyze_multi_sweep_spikes:
+ In doc, not in sig: {'Detect'}
+ In sig, not in doc: {'time_vector', 'threshold', 'refractory_samples', 'data_trials', 'dvdt_threshold'}
+src/Synaptipy/core/analysis/single_spike.py::calculate_dvdt:
+ In doc, not in sig: {'the', 'Calculate', 'which', 'next', 'Computes'}
+src/Synaptipy/core/analysis/single_spike.py::get_phase_plane_trajectory:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'sampling_rate', 'sigma_ms', 'voltage'}
+src/Synaptipy/core/analysis/single_spike.py::detect_threshold_kink:
+ In doc, not in sig: {'Detect'}
+ In sig, not in doc: {'kink_slope', 'search_window_ms', 'peak_indices', 'voltage', 'sampling_rate', 'dvdt_threshold'}
+src/Synaptipy/core/analysis/single_spike.py::run_spike_detection_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'threshold', 'refractory_period', 'data', 'ahp_window', 'sampling_rate', 'onset_lookback', 'peak_search_window', 'time', 'dvdt_threshold'}
+src/Synaptipy/core/analysis/single_spike.py::phase_plane_analysis_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'voltage', 'sampling_rate', 'sigma_ms', 'time', 'dvdt_threshold'}
+src/Synaptipy/core/analysis/single_spike.py::_window_min:
+ In doc, not in sig: {'Return'}
+ In sig, not in doc: {'end_s', 'start_s'}
+src/Synaptipy/core/analysis/evoked_responses.py::extract_ttl_epochs:
+ In doc, not in sig: {'Tuple', 'Extract'}
+ In sig, not in doc: {'ttl_data', 'auto_threshold', 'time', 'threshold'}
+src/Synaptipy/core/analysis/evoked_responses.py::_find_spikes_in_window:
+ In doc, not in sig: {'Vectorised'}
+ In sig, not in doc: {'spikes', 't_end', 't_start'}
+src/Synaptipy/core/analysis/evoked_responses.py::calculate_optogenetic_sync:
+ In doc, not in sig: {'Correlate'}
+src/Synaptipy/core/analysis/evoked_responses.py::_peak_pos_s:
+ In doc, not in sig: {'Searches', 'value', 'Tuple', 'Return', 'matching'}
+src/Synaptipy/core/analysis/evoked_responses.py::calculate_paired_pulse_ratio:
+ In doc, not in sig: {'Calculate', 'baseline', 'Dict', 'when', 'yielding', 'under', 'Without', 'Should', 'Algorithm', 'response', 'stimulus'}
+src/Synaptipy/core/analysis/evoked_responses.py::run_opto_sync_wrapper:
+ In doc, not in sig: {'Correlates', 'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/evoked_responses.py::run_ppr_wrapper:
+ In doc, not in sig: {'Wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/evoked_responses.py::calculate_stimulus_train_stp:
+ In doc, not in sig: {'preceding', 'Dictionary', 'yield', 'Compute', 'window', 'excluded', 'For'}
+src/Synaptipy/core/analysis/evoked_responses.py::run_stimulus_train_stp_wrapper:
+ In doc, not in sig: {'generated', 'Wrapper', 'series', 'Stimulus', 'wrapper'}
+ In sig, not in doc: {'sampling_rate', 'data', 'time'}
+src/Synaptipy/core/analysis/evoked_responses.py::_response_peak:
+ In doc, not in sig: {'Data', 'Return'}
+ In sig, not in doc: {'stim_onset_s', 'baseline'}
diff --git a/docs/algorithmic_definitions.md b/docs/algorithmic_definitions.md
index cd0b5e77..0e13280e 100644
--- a/docs/algorithmic_definitions.md
+++ b/docs/algorithmic_definitions.md
@@ -308,7 +308,7 @@ refractory period between crossings. The peak is the local maximum within
*(See also §15.2 for the full maximum-curvature method; cited in Sekerli et al., 2004.)*
*(Default threshold $\theta_{dV/dt}$ = 20 V/s, calibrated on rodent cortical pyramidal neurons; Bean, 2007.)*
-*(Artifact ceiling: 300 V/s — above this value the rising phase is flagged as non-physiological; Naundorf et al., 2006.)*
+*(Artifact ceiling: 300 V/s - above this value the rising phase is flagged as non-physiological; Naundorf et al., 2006.)*
The action potential onset is defined as the first point where
@@ -533,7 +533,12 @@ p < 0.003 under Gaussian noise assumption.)*
Uses sliding-window variance minimisation to find the most stable baseline
segment, estimates noise as $\hat{\sigma} = 1.4826 \times \text{MAD}$
(Hampel, 1974; Rousseeuw & Croux, 1993) in that region, and detects peaks
-with prominence $\ge 0.5 \times \text{threshold}$.
+with a specified prominence. The prominence is determined by `peak_prominence_factor`
+if provided; otherwise, it defaults to $\ge 0.5 \times \text{threshold}$.
+
+A low-pass Butterworth filter can be applied prior to peak detection by
+specifying `filter_freq_hz`. This attenuates high-frequency noise that might
+cause spurious local maxima.
### 7.4 Local Pre-Event Baseline (Dynamic Amplitude for Summating Events)
@@ -747,6 +752,10 @@ eliminating group delay distortion critical for preserving AP waveform timing:
| Notch | $H(z)$ from `scipy.signal.iirnotch` at centre $f_0$, quality $Q$ |
| Comb | Cascaded notch at $f_0, 2f_0, \ldots, Nf_0$ |
+**Unit Enforcement:** All inputs passing through the `ProcessingPipeline` and
+signal processors are strictly validated and tracked using the `quantities` package
+to prevent dimensional mismatches.
+
### 14.2 Baseline subtraction
| Method | Formula |
@@ -944,7 +953,7 @@ relative to the neighboring baseline band identifies mains interference.
modified periodograms. *IEEE Transactions on Audio and Electroacoustics*,
15(2), 70-73.
[doi:10.1109/TAU.1967.1161901](https://doi.org/10.1109/TAU.1967.1161901)
- - **Used in:** §14.4 trace quality assessment — line noise detection at
+ - **Used in:** §14.4 trace quality assessment - line noise detection at
50/60 Hz via `scipy.signal.welch`; also used in the `compute_psd` utility
in `signal_processor.py`.
@@ -954,7 +963,7 @@ relative to the neighboring baseline band identifies mains interference.
estimation. *Journal of the American Statistical Association*, 69(346),
383-393.
[doi:10.1080/01621459.1974.10482962](https://doi.org/10.1080/01621459.1974.10482962)
- - **Used in:** §7.1, §7.2, §7.3 — the 1.4826 consistency factor applied to
+ - **Used in:** §7.1, §7.2, §7.3 - the 1.4826 consistency factor applied to
the Median Absolute Deviation (MAD) to obtain a Gaussian-consistent
standard deviation estimate: $\hat{\sigma} = 1.4826 \times \text{MAD}$.
The factor 1.4826 = $1 / \Phi^{-1}(0.75)$ ensures the estimator is
@@ -964,7 +973,7 @@ relative to the neighboring baseline band identifies mains interference.
absolute deviation. *Journal of the American Statistical Association*,
88(424), 1273-1283.
[doi:10.1080/01621459.1993.10476408](https://doi.org/10.1080/01621459.1993.10476408)
- - **Used in:** MAD noise estimator (§7.1, §7.2, §7.3) — robustness
+ - **Used in:** MAD noise estimator (§7.1, §7.2, §7.3) - robustness
properties and breakdown point of the MAD as a scale estimator for
contaminated electrophysiology data.
@@ -974,7 +983,7 @@ relative to the neighboring baseline band identifies mains interference.
and small cell effects in patch-clamp analysis. *Journal of Membrane
Biology*, 121(2), 101-117.
[doi:10.1007/BF01870526](https://doi.org/10.1007/BF01870526)
- - **Used in:** §16 Step A — liquid junction potential subtraction
+ - **Used in:** §16 Step A - liquid junction potential subtraction
$V_{\text{true}} = V_{\text{recorded}} - \text{LJP}$. Defines the
correction procedure and its biophysical limitations for voltage-dependent
analyses.
@@ -983,7 +992,7 @@ relative to the neighboring baseline band identifies mains interference.
channel. II. Gating current experiments. *Journal of General Physiology*,
70(5), 567-590.
[doi:10.1085/jgp.70.5.567](https://doi.org/10.1085/jgp.70.5.567)
- - **Used in:** §16 Step B — P/N leak subtraction protocol. Armstrong and
+ - **Used in:** §16 Step B - P/N leak subtraction protocol. Armstrong and
Bezanilla introduced the P/N subtraction technique for isolating
non-linear gating currents by scaling and subtracting linear leak sweeps.
@@ -991,7 +1000,7 @@ relative to the neighboring baseline band identifies mains interference.
channel. I. Sodium current experiments. *Journal of General Physiology*,
70(5), 549-566.
[doi:10.1085/jgp.70.5.549](https://doi.org/10.1085/jgp.70.5.549)
- - **Used in:** §16 Step B — companion paper establishing the P/N
+ - **Used in:** §16 Step B - companion paper establishing the P/N
subtraction protocol for leak correction in voltage-clamp recordings.
### Foundational neuroscience
@@ -1000,7 +1009,7 @@ relative to the neighboring baseline band identifies mains interference.
of membrane current and its application to conduction and excitation in
nerve. *Journal of Physiology*, 117(4), 500-544.
[doi:10.1113/jphysiol.1952.sp004764](https://doi.org/10.1113/jphysiol.1952.sp004764)
- - **Used in:** §15.2 — foundational mathematical model of action potential
+ - **Used in:** §15.2 - foundational mathematical model of action potential
generation via voltage-gated Na$^+$ and K$^+$ conductances. Provides the
biophysical basis for AP threshold detection, dV/dt methods, and Na$^+$
channel inactivation effects on spike trains.
@@ -1009,14 +1018,14 @@ relative to the neighboring baseline band identifies mains interference.
cation currents: From molecules to physiological function. *Annual Review of
Physiology*, 65, 453-480.
[doi:10.1146/annurev.physiol.65.092101.142734](https://doi.org/10.1146/annurev.physiol.65.092101.142734)
- - **Used in:** §2.2 and §4 — physiological context for HCN channel-mediated
+ - **Used in:** §2.2 and §4 - physiological context for HCN channel-mediated
$I_h$ current, voltage sag during hyperpolarisation, and the distinction
between peak and steady-state input resistance as a diagnostic for $I_h$.
- **Neher, E. (1992).** Correction for liquid junction potentials in patch
clamp experiments. *Methods in Enzymology*, 207, 123-131.
[doi:10.1016/0076-6879(92)07008-C](https://doi.org/10.1016/0076-6879(92)07008-C)
- - **Used in:** §16 Step A — standard reference for LJP correction in
+ - **Used in:** §16 Step A - standard reference for LJP correction in
whole-cell patch-clamp. Provides the accepted procedure for calculating
and applying the junction potential offset.
diff --git a/docs/conf.py b/docs/conf.py
index d1311d28..2ece2fda 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -50,8 +50,8 @@
# Project information
# ---------------------------------------------------------------------------
project = "Synaptipy"
-copyright = f"2024-{_dt.datetime.now().year}, Anzal K Shahul"
-author = "Anzal K Shahul"
+copyright = f"2024-{_dt.datetime.now().year}, Anzal K. Shahul"
+author = "Anzal K. Shahul"
# Retrieve version from the package itself
try:
@@ -204,7 +204,7 @@
master_doc,
"Synaptipy.tex",
"Synaptipy Documentation",
- "Anzal K Shahul",
+ "Anzal K. Shahul",
"manual",
),
]
diff --git a/docs/developer_guide.md b/docs/developer_guide.md
index 8c94cc9e..614f95f3 100644
--- a/docs/developer_guide.md
+++ b/docs/developer_guide.md
@@ -197,6 +197,26 @@ For the complete reference - including all `ui_params` types, `plots` types,
return-dict conventions, `visible_when` rules, and a fully annotated example -
see the dedicated guide: **[Writing Custom Analysis Plugins](extending_synaptipy.md)**.
+## Architecture & Performance Patterns
+
+### Asynchronous UI & Background Loading
+
+Synaptipy leverages `QThread` and `QRunnable` to execute heavy data loading and analysis in the background. Specifically, `.abf` and `.wcp` files are loaded asynchronously to prevent UI freezes.
+
+Because of this asynchronous architecture, direct UI assertions in tests can be flaky if they do not account for background processing. Developers must use `qtbot.waitUntil` to synchronize test execution with the completion of background threads.
+
+### UI Interaction Debouncing
+
+To ensure a smooth user experience, rapid interactions are debounced. For instance, zoom and pan signals in `explorer_tab.py` employ a 50ms interaction debouncing mechanism. This 50ms timer is imperceptible to the user but prevents the application from being overwhelmed by consecutive resize or scroll events, optimizing rendering performance.
+
+## UI & Styling Guidelines
+
+Synaptipy prioritizes visual accessibility. By default, multi-trial plotting utilizes a colorblind-safe palette (e.g., Okabe-Ito or Viridis).
+
+When writing custom plugins or extending the core UI:
+- **Do not** hardcode overlapping red/green traces.
+- Rely on the application's predefined palette generators to maintain a coherent and seamless visual experience for all users.
+
## Development Workflow
### Feature Development
diff --git a/docs/index.rst b/docs/index.rst
index 08e573c1..e2df905a 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -55,7 +55,7 @@ is implemented via the `Neo `_ library, w
over 30 acquisition formats including extracellular and multi-channel data.
The software is implemented in Python using the Qt6 framework (PySide6). Signal visualization
-employs GPU-accelerated rendering via PyQtGraph. The application includes 17 built-in analysis
+employs PyQtGraph's CPU-vectorized native raster engine. The application includes 17 built-in analysis
modules spanning intrinsic membrane properties, action potential characterization, synaptic
event detection, and evoked responses (Evoked Sync, Paired-Pulse Ratio, Stimulus Train STP).
A batch processing engine implements composable analysis pipelines. An extensible plugin
@@ -67,6 +67,12 @@ Intan, Igor Pro, NWB, Open Ephys, and additional formats. NWB 2.x export is prov
The source code is hosted on `GitHub `_.
+.. note::
+ **Graphics Engine Architecture:** SynaptiPy's interactive workspace relies entirely on
+ PyQtGraph's highly optimized, CPU-vectorized native raster engine for rendering
+ high-density 2D electrophysiology traces. Matplotlib is utilized strictly for offline,
+ high-resolution static figure exporting and validation reporting.
+
.. grid:: 2
.. grid-item-card:: Tutorial
@@ -166,12 +172,6 @@ The source code is hosted on `GitHub `_.
development/index
decisions/index
manuals/index
-
-.. toctree::
- :maxdepth: 1
- :caption: Development Logs
- :hidden:
-
development_logs/index
Indices and Tables
diff --git a/docs/tutorial/screenshots/empirical_validation.png b/docs/tutorial/screenshots/empirical_validation.png
new file mode 100644
index 00000000..8c544da7
Binary files /dev/null and b/docs/tutorial/screenshots/empirical_validation.png differ
diff --git a/docs/user_guide.md b/docs/user_guide.md
index 08a2e3c2..a7e99868 100644
--- a/docs/user_guide.md
+++ b/docs/user_guide.md
@@ -211,6 +211,8 @@ screen. The default widths are 320 px (left), 800 px (centre), and 360 px
- **Plot Mode**:
- "Overlay All + Avg": Shows all trials with the average highlighted
- "Cycle Single Trial": Shows one trial at a time with navigation controls
+- **Performance**:
+ - `_force_opaque_trials`: For datasets with >10 overlapping trials, you can enable "Force Opaque Trials" in Plot Preferences to improve rendering performance.
- **Cross-File Trial Averaging**:
While in "Cycle Single Trial" mode, you can manually build a grand average
from any combination of files and trials across the entire session:
diff --git a/pyproject.toml b/pyproject.toml
index 4e4c7688..0eddd33f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "synaptipy"
version = "0.1.5b7"
description = "Electrophysiology Visualization Suite"
authors = [
- {name = "Anzal K Shahul", email = "anzal.ks@gmail.com"}
+ {name = "Anzal K. Shahul", email = "anzal.ks@gmail.com"}
]
readme = "README.md"
license = {text = "AGPL-3.0-or-later"}
@@ -144,7 +144,6 @@ known_first_party = ["Synaptipy"]
[tool.coverage.run]
source = ["src/Synaptipy"]
-relative_files = true
omit = [
"*/tests/*",
"*/__pycache__/*",
@@ -191,22 +190,13 @@ omit = [
"*/controllers/analysis_plot_manager.py",
"*/controllers/file_io_controller.py",
# Infrastructure with heavy external format dependencies
- "*/exporters/nwb_exporter.py",
]
[tool.coverage.paths]
# Map GitHub Actions runner absolute paths → local src-layout paths.
# coverage.py rewrites any match below to the first (canonical) entry before
# generating reports, ensuring coverage.xml contains repo-relative paths.
-source = [
- "src/Synaptipy",
- # Linux runner (anzalks/synaptipy → /home/runner/work/synaptipy/synaptipy/)
- "/home/runner/work/synaptipy/synaptipy/src/Synaptipy",
- # macOS runner (/Users/runner/work/synaptipy/synaptipy/)
- "/Users/runner/work/synaptipy/synaptipy/src/Synaptipy",
- # Windows runner (D:\a\synaptipy\synaptipy\)
- "D:\\a\\synaptipy\\synaptipy\\src\\Synaptipy",
-]
+source = ["src", "/home/runner/work/synaptipy/synaptipy/src", "/Users/runner/work/synaptipy/synaptipy/src", "D:\\a\\synaptipy\\synaptipy\\src"]
[tool.coverage.report]
show_missing = true
diff --git a/scripts/benchmark_e2e.py b/scripts/benchmark_e2e.py
index b6903a8d..806d3278 100644
--- a/scripts/benchmark_e2e.py
+++ b/scripts/benchmark_e2e.py
@@ -58,7 +58,21 @@
_SCRIPT_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _SCRIPT_DIR.parent
_SRC_DIR = _REPO_ROOT / "src"
-_ABF_0021 = _REPO_ROOT / "examples" / "data" / "2023_04_11_0021.abf"
+
+
+def _get_abf_path():
+ target = _REPO_ROOT / "examples" / "data" / "2023_04_11_0021.abf"
+ if target.exists():
+ return target
+ data_dir = _REPO_ROOT / "examples" / "data"
+ if data_dir.exists():
+ abfs = list(data_dir.glob("*.abf"))
+ if abfs:
+ return abfs[0]
+ return target
+
+
+_ABF_0021 = _get_abf_path()
# N of trials overlaid in OVERLAY_AVG benchmark.
# 0021.abf has 20 trials; levels are clamped to min(N, n_all).
@@ -535,9 +549,9 @@ def main() -> int:
parser.add_argument(
"--output-dir",
type=Path,
- default=_REPO_ROOT / "paper" / "results",
+ default=_REPO_ROOT / "benchmarks" / "results",
metavar="PATH",
- help="Destination for CSV and PNG (default: paper/results/).",
+ help="Destination for CSV and PNG (default: benchmarks/results/).",
)
parser.add_argument(
"--_child",
diff --git a/scripts/benchmark_rendering.py b/scripts/benchmark_rendering.py
index 93f4841d..757f9079 100644
--- a/scripts/benchmark_rendering.py
+++ b/scripts/benchmark_rendering.py
@@ -71,7 +71,21 @@
_SCRIPT_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _SCRIPT_DIR.parent
_SRC_DIR = _REPO_ROOT / "src"
-_ABF_0021 = _REPO_ROOT / "examples" / "data" / "2023_04_11_0021.abf"
+
+
+def _get_abf_path():
+ target = _REPO_ROOT / "examples" / "data" / "2023_04_11_0021.abf"
+ if target.exists():
+ return target
+ data_dir = _REPO_ROOT / "examples" / "data"
+ if data_dir.exists():
+ abfs = list(data_dir.glob("*.abf"))
+ if abfs:
+ return abfs[0]
+ return target
+
+
+_ABF_0021 = _get_abf_path()
# N=40 is excluded: the source file has 20 trials; at N=40 the i%20 indexing
# cycles through all arrays twice per update, placing the entire dataset in L2
@@ -91,7 +105,8 @@
def _run_child(mode: str) -> None:
"""Run the rendering benchmark in-process and print JSON to stdout."""
- use_opengl = mode == "opengl"
+ use_opengl = mode.startswith("opengl")
+ force_opaque = "opaque" in mode
if str(_SRC_DIR) not in sys.path:
sys.path.insert(0, str(_SRC_DIR))
@@ -108,6 +123,10 @@ def _run_child(mode: str) -> None:
pg.setConfigOption("background", "k")
pg.setConfigOption("foreground", "w")
+ from Synaptipy.shared.plot_customization import set_force_opaque_trials
+
+ set_force_opaque_trials(force_opaque)
+
from PySide6.QtWidgets import QApplication, QGridLayout, QWidget
app = QApplication.instance() or QApplication(sys.argv)
@@ -245,18 +264,20 @@ def _spawn_child(mode: str) -> dict:
print(f"WARNING: no output from child [{mode}]")
return {}
try:
- return json.loads(lines[-1])
+ data = json.loads(lines[-1])
+ data["_mode"] = mode # pass the mode back for identifying
+ return data
except json.JSONDecodeError as exc:
print(f"WARNING: could not parse child output [{mode}]: {exc}")
return {}
-def _save_csv(opengl_data: dict, software_data: dict, output_path: Path) -> None:
+def _save_csv(results_dict: dict, output_path: Path) -> None:
"""Write rendering benchmark results to CSV."""
import csv
fieldnames = [
- "renderer",
+ "renderer_mode",
"n_trials",
"total_samples",
"median_ms",
@@ -264,24 +285,21 @@ def _save_csv(opengl_data: dict, software_data: dict, output_path: Path) -> None
"p95_ms",
]
rows = []
- for n_trials, data in sorted(opengl_data.items(), key=lambda x: int(x[0])):
- rows.append(
- {
- "renderer": "opengl",
- "n_trials": n_trials,
- "total_samples": int(n_trials) * 20000,
- **data,
- }
- )
- for n_trials, data in sorted(software_data.items(), key=lambda x: int(x[0])):
- rows.append(
- {
- "renderer": "software",
- "n_trials": n_trials,
- "total_samples": int(n_trials) * 20000,
- **data,
- }
- )
+ for mode_name, data_set in results_dict.items():
+ if not data_set:
+ continue
+ for n_trials, data in sorted(data_set.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 0):
+ if n_trials == "_mode":
+ continue
+ rows.append(
+ {
+ "renderer_mode": mode_name,
+ "n_trials": n_trials,
+ "total_samples": int(n_trials) * 20000,
+ **data,
+ }
+ )
+
with open(output_path, "w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
@@ -403,34 +421,30 @@ def main(output_dir: Path) -> None:
"""Orchestrate child processes and save results."""
output_dir.mkdir(parents=True, exist_ok=True)
- print("Spawning software-renderer child process ...")
- software_data = _spawn_child("software")
- print("Spawning OpenGL-renderer child process ...")
- opengl_data = _spawn_child("opengl")
+ results_dict = {}
+ modes = ["software_transparent", "software_opaque", "opengl_transparent", "opengl_opaque"]
- if not software_data and not opengl_data:
- print("ERROR: both child processes failed — no results to save.")
- sys.exit(1)
+ for m in modes:
+ print(f"Spawning {m} child process ...")
+ results_dict[m] = _spawn_child(m)
- _save_csv(opengl_data, software_data, output_dir / f"rendering_results_{_OS_TAG}.csv")
- tagged_png = output_dir / f"rendering_benchmark_{_OS_TAG}.png"
- _save_plot(opengl_data, software_data, tagged_png)
- # Copy to canonical filename referenced by paper/paper.md (Figure 2)
- canonical_png = output_dir / "rendering_benchmark.png"
- if tagged_png.exists():
- import shutil as _shutil
+ if not any(results_dict.values()):
+ print("ERROR: all child processes failed — no results to save.")
+ sys.exit(1)
- _shutil.copy2(tagged_png, canonical_png)
- print(f"Copied canonical: {canonical_png}")
+ _save_csv(results_dict, output_dir / f"rendering_results_{_OS_TAG}.csv")
print("\nSummary (median ms per update cycle):")
- print(f" {'N trials':>8} {'Samples':>8} {'Software':>10} {'OpenGL':>8} {'Ratio (SW/GL)':>14}")
+ print(
+ f" {'N trials':>8} {'Samples':>8} {'SW_Trans':>10} {'SW_Opaque':>10} {'GL_Trans':>10} {'GL_Opaque':>10}"
+ )
for n in _N_TRIALS_LEVELS:
key = str(n)
- sw = software_data.get(key, {}).get("median_ms", float("nan"))
- gl = opengl_data.get(key, {}).get("median_ms", float("nan"))
- ratio = sw / gl if gl and gl > 0 else float("nan")
- print(f" {n:>8} {n * 20000:>8} {sw:>10.2f} {gl:>8.2f} {ratio:>14.2f}")
+ st = results_dict["software_transparent"].get(key, {}).get("median_ms", float("nan"))
+ so = results_dict["software_opaque"].get(key, {}).get("median_ms", float("nan"))
+ gt = results_dict["opengl_transparent"].get(key, {}).get("median_ms", float("nan"))
+ go = results_dict["opengl_opaque"].get(key, {}).get("median_ms", float("nan"))
+ print(f" {n:>8} {n * 20000:>8} {st:>10.2f} {so:>10.2f} {gt:>10.2f} {go:>10.2f}")
if __name__ == "__main__":
@@ -438,12 +452,12 @@ def main(output_dir: Path) -> None:
parser.add_argument(
"--output-dir",
type=Path,
- default=_REPO_ROOT / "paper" / "results",
- help="Output directory for CSV and PNG (default: paper/results/)",
+ default=_REPO_ROOT / "benchmarks" / "results",
+ help="Output directory for CSV and PNG (default: benchmarks/results/)",
)
parser.add_argument(
"--_child",
- choices=["opengl", "software"],
+ choices=["opengl_transparent", "opengl_opaque", "software_transparent", "software_opaque"],
help=argparse.SUPPRESS, # internal flag used by subprocess invocation
)
args = parser.parse_args()
diff --git a/src/Synaptipy/application/__main__.py b/src/Synaptipy/application/__main__.py
index b00404fa..4f5c0f0e 100644
--- a/src/Synaptipy/application/__main__.py
+++ b/src/Synaptipy/application/__main__.py
@@ -263,10 +263,15 @@ def run_gui(): # noqa: C901
if dev_mode:
log.info("Running in DEVELOPMENT mode with verbose logging")
- # Create Qt Application with High DPI support
app = QtWidgets.QApplication.instance()
if app is None:
# Enable High DPI scaling before creating QApplication
+ try:
+ QtCore.QCoreApplication.setAttribute(QtCore.Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
+ QtCore.QCoreApplication.setAttribute(QtCore.Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
+ except Exception as e:
+ log.warning(f"Could not set High DPI attributes: {e}")
+
# Check if HighDpiScaleFactorRoundingPolicy is available (Qt 6.0+)
if hasattr(QtCore.Qt, "HighDpiScaleFactorRoundingPolicy"):
try:
diff --git a/src/Synaptipy/application/gui/explorer/explorer_tab.py b/src/Synaptipy/application/gui/explorer/explorer_tab.py
index b5c54d91..80f10147 100644
--- a/src/Synaptipy/application/gui/explorer/explorer_tab.py
+++ b/src/Synaptipy/application/gui/explorer/explorer_tab.py
@@ -599,6 +599,13 @@ def load_recording_data( # noqa: C901
return
self._is_loading = True
+ # Add visual loading indicator
+ self._loading_dialog = QtWidgets.QProgressDialog(f"Loading {filepath.name}...", None, 0, 0, self)
+ self._loading_dialog.setWindowTitle("Please Wait")
+ self._loading_dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
+ self._loading_dialog.setCancelButton(None)
+ self._loading_dialog.show()
+
if file_list is None:
file_list = [filepath]
if selected_index == -1:
@@ -1456,6 +1463,9 @@ def _on_file_load_error(self, error: Exception, filepath: Path):
def _finalize_loading_state(self):
self._is_loading = False
+ if hasattr(self, "_loading_dialog") and self._loading_dialog:
+ self._loading_dialog.close()
+ self._loading_dialog = None
def _update_all_ui_state(self):
# Delegate to subcomponents or handle locally?
diff --git a/src/Synaptipy/application/gui/session_summary_dialog.py b/src/Synaptipy/application/gui/session_summary_dialog.py
index 9bf0b76f..ade4cf1a 100644
--- a/src/Synaptipy/application/gui/session_summary_dialog.py
+++ b/src/Synaptipy/application/gui/session_summary_dialog.py
@@ -41,6 +41,19 @@ def _setup_ui(self):
self._populate_table()
self._calculate_stats()
+ # 3. Methods Paragraph
+ methods_group = QtWidgets.QGroupBox("Methods Paragraph")
+ methods_layout = QtWidgets.QVBoxLayout(methods_group)
+ self.methods_text = QtWidgets.QTextEdit()
+ self.methods_text.setReadOnly(True)
+ self.methods_text.setText(self._generate_methods_paragraph())
+ methods_layout.addWidget(self.methods_text)
+
+ copy_btn = QtWidgets.QPushButton("Copy Methods")
+ copy_btn.clicked.connect(lambda: QtWidgets.QApplication.clipboard().setText(self.methods_text.toPlainText()))
+ methods_layout.addWidget(copy_btn)
+ layout.addWidget(methods_group)
+
# Close button
close_btn = QtWidgets.QPushButton("Close")
close_btn.clicked.connect(self.accept)
@@ -95,3 +108,25 @@ def _calculate_stats(self):
label = key.replace("_", " ").title()
self.stats_layout.addRow(f"{label}:", QtWidgets.QLabel(f"{mean_val:.4g} ± {std_val:.4g}"))
+
+ def _generate_methods_paragraph(self) -> str:
+ try:
+ from Synaptipy import __version__
+
+ version = __version__
+ except ImportError:
+ version = "unknown"
+
+ if not self.results:
+ return f"Data was analyzed using Synaptipy {version}."
+
+ sample = self.results[0]
+ freq = sample.get("matched_filter_freq", "X")
+ prominence = sample.get("prominence_factor", "Y")
+
+ # You can expand this logic to extract more actual parameters
+ return (
+ f"Data was analyzed using Synaptipy {version}. "
+ f"Event detection utilized a matched filter (freq: {freq} Hz) "
+ f"with a prominence factor of {prominence}."
+ )
diff --git a/src/Synaptipy/application/session_manager.py b/src/Synaptipy/application/session_manager.py
index 4d6fb2c0..930da28a 100644
--- a/src/Synaptipy/application/session_manager.py
+++ b/src/Synaptipy/application/session_manager.py
@@ -234,7 +234,7 @@ def performance_settings(self, settings: Dict[str, Any]) -> None:
log.warning("performance_settings must be a dict, got %s.", type(settings).__name__)
return
self._performance_settings.update(settings)
- log.debug("SessionManager: performance_settings updated: %s", self._performance_settings)
+ log.debug(f"SessionManager: performance_settings updated: {self._performance_settings}")
self.preferences_changed.emit(dict(self._performance_settings))
def emit_preferences_changed(self) -> None:
diff --git a/src/Synaptipy/core/analysis/passive_properties.py b/src/Synaptipy/core/analysis/passive_properties.py
index fb7bf09e..0681ec9d 100644
--- a/src/Synaptipy/core/analysis/passive_properties.py
+++ b/src/Synaptipy/core/analysis/passive_properties.py
@@ -18,6 +18,7 @@
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
+import quantities as pq
from scipy.optimize import curve_fit
from scipy.stats import linregress
@@ -256,7 +257,7 @@ def find_stable_baseline(
# ---------------------------------------------------------------------------
-def calculate_rin(
+def calculate_rin( # noqa: C901
voltage_trace: np.ndarray,
time_vector: np.ndarray,
current_amplitude: float,
@@ -346,6 +347,16 @@ def calculate_rin(
parameters=parameters or {},
)
+ # Unit checks using quantities package
+ if hasattr(voltage_trace, "units") and hasattr(current_amplitude, "units"):
+ try:
+ voltage_trace.rescale(pq.V)
+ current_amplitude.rescale(pq.A)
+ except ValueError as e:
+ raise ValueError(
+ f"Incompatible units passed: voltage must be in V (e.g. mV) and current in A (e.g. pA) when calculating resistance. Details: {e}" # noqa: E501
+ )
+
if delta_i_pa == 0.0:
log.warning("Cannot calculate Rin: Current amplitude is zero.")
return RinResult(value=float(np.nan), unit="MOhm", is_valid=False, error_message="Current amplitude is zero")
@@ -742,6 +753,17 @@ def calculate_conductance(
error_message="Voltage step is zero",
parameters=parameters or {},
)
+
+ # Unit checks using quantities package
+ if hasattr(current_trace, "units") and hasattr(voltage_step, "units"):
+ try:
+ current_trace.rescale(pq.A)
+ voltage_step.rescale(pq.V)
+ except ValueError as e:
+ raise ValueError(
+ f"Incompatible units passed: voltage must be in V (e.g. mV) and current in A (e.g. pA) when calculating conductance. Details: {e}" # noqa: E501
+ )
+
try:
baseline_mask = (time_vector >= baseline_window[0]) & (time_vector < baseline_window[1])
response_mask = (time_vector >= response_window[0]) & (time_vector < response_window[1])
diff --git a/src/Synaptipy/core/analysis/synaptic_events.py b/src/Synaptipy/core/analysis/synaptic_events.py
index 5643f896..6d4278c3 100644
--- a/src/Synaptipy/core/analysis/synaptic_events.py
+++ b/src/Synaptipy/core/analysis/synaptic_events.py
@@ -1280,8 +1280,41 @@ def detect_events_baseline_peak_kinetics( # noqa: C901
min_event_separation_ms: float = 5.0,
auto_baseline: bool = True,
rolling_baseline_window_ms: float = 0.0,
+ peak_prominence_factor: Optional[float] = None,
) -> EventDetectionResult:
- """Detect events via stable-baseline estimation then prominence-based peak finding."""
+ """
+ Detect events via stable-baseline estimation then prominence-based peak finding.
+
+ Parameters
+ ----------
+ data : np.ndarray
+ Raw data trace.
+ sample_rate : float
+ Sampling rate in Hz.
+ direction : str, optional
+ "negative" or "positive", by default "negative".
+ baseline_window_s : float, optional
+ Window for stable baseline search in seconds, by default 0.5.
+ baseline_step_s : float, optional
+ Step size for stable baseline search in seconds, by default 0.1.
+ threshold_sd_factor : float, optional
+ Detection threshold as a multiple of baseline standard deviation, by default 3.0.
+ filter_freq_hz : float, optional
+ Low-pass filter frequency in Hz, by default None.
+ min_event_separation_ms : float, optional
+ Minimum separation between events in ms, by default 5.0.
+ auto_baseline : bool, optional
+ Whether to perform automatic baseline correction, by default True.
+ rolling_baseline_window_ms : float, optional
+ Window for rolling median baseline correction in ms, by default 0.0.
+ peak_prominence_factor : float, optional
+ Custom prominence for peak detection. If None, defaults to `threshold_val * 0.5`.
+
+ Returns
+ -------
+ EventDetectionResult
+ Detection results including indices and stats.
+ """
if direction not in ["negative", "positive"]:
return EventDetectionResult(value=0, unit="counts", is_valid=False, error_message="Invalid direction")
@@ -1327,10 +1360,13 @@ def detect_events_baseline_peak_kinetics( # noqa: C901
min_dist = max(1, int(min_event_separation_ms / 1000.0 * sample_rate))
min_width = max(2, int(0.0002 * sample_rate))
+
+ prominence_val = peak_prominence_factor if peak_prominence_factor is not None else threshold_val * 0.5
+
peaks, _ = signal.find_peaks(
filtered,
height=threshold_val,
- prominence=threshold_val * 0.5,
+ prominence=prominence_val,
distance=min_dist,
width=min_width,
)
@@ -1367,6 +1403,16 @@ def detect_events_baseline_peak_kinetics( # noqa: C901
},
{"name": "auto_baseline", "label": "Auto-Detect Baseline", "type": "bool", "default": True},
{"name": "threshold_sd_factor", "label": "Threshold (SD Factor):", "type": "float", "default": 3.0},
+ {
+ "name": "peak_prominence_factor",
+ "label": "Peak Prominence:",
+ "type": "float",
+ "default": 0.0,
+ "min": 0.0,
+ "max": 1e9,
+ "decimals": 2,
+ "tooltip": "Custom prominence factor. 0.0 means default (Threshold * 0.5).",
+ },
{
"name": "min_event_separation_ms",
"label": "Min Separation (ms):",
@@ -1410,6 +1456,9 @@ def run_event_detection_baseline_peak_wrapper(
) -> Dict[str, Any]:
"""Wrapper for baseline-peak event detection."""
direction = kwargs.get("direction", "negative")
+ peak_prominence = kwargs.get("peak_prominence_factor", 0.0)
+ prominence_param = peak_prominence if peak_prominence > 0 else None
+
result = detect_events_baseline_peak_kinetics(
data,
sampling_rate,
@@ -1420,6 +1469,7 @@ def run_event_detection_baseline_peak_wrapper(
baseline_window_s=kwargs.get("baseline_window_s", 0.5),
baseline_step_s=kwargs.get("baseline_step_s", 0.1),
rolling_baseline_window_ms=kwargs.get("rolling_baseline_window_ms", 100.0),
+ peak_prominence_factor=prominence_param,
)
if not result.is_valid:
return {"module_used": "synaptic_events", "metrics": {"event_error": result.error_message}}
diff --git a/src/Synaptipy/infrastructure/exporters/nwb_exporter.py b/src/Synaptipy/infrastructure/exporters/nwb_exporter.py
index 644cb931..64ef9b87 100644
--- a/src/Synaptipy/infrastructure/exporters/nwb_exporter.py
+++ b/src/Synaptipy/infrastructure/exporters/nwb_exporter.py
@@ -300,6 +300,9 @@ def export( # noqa: C901
output_path: Path,
session_metadata: Dict[str, Any],
analysis_results: Optional[Dict[str, Any]] = None,
+ subject_id: Optional[str] = None,
+ session_start_time: Optional[datetime] = None,
+ device_description: Optional[str] = None,
):
"""
Exports the given Recording object to an NWB file.
@@ -335,24 +338,31 @@ def export( # noqa: C901
raise ValueError("Recording object is missing 'channels' dictionary or is not a dictionary.")
# --- Inject required metadata defaults for DANDI compliance ---
+ session_metadata = dict(session_metadata) # avoid mutating caller's dict
+ if subject_id:
+ session_metadata["subject_id"] = subject_id
+ if session_start_time:
+ session_metadata["session_start_time"] = session_start_time
+ if device_description:
+ session_metadata["device_description"] = device_description
+
# Subject ID and Species are required by NWB/DANDI validators.
if not session_metadata.get("subject_id"):
- session_metadata = dict(session_metadata) # avoid mutating caller's dict
- session_metadata.setdefault("subject_id", "unknown_subject")
- log.warning("NWB export: 'subject_id' not provided; defaulting to 'unknown_subject'.")
+ raise ValueError("NWB export requires a valid 'subject_id' MINDS metadata field.")
if not session_metadata.get("species"):
session_metadata.setdefault("species", "unknown species")
log.warning("NWB export: 'species' not provided; defaulting to 'unknown species'.")
if not session_metadata.get("device_name"):
- session_metadata = dict(session_metadata)
session_metadata.setdefault("device_name", "Generic Amplifier")
log.warning("NWB export: 'device_name' not provided; defaulting to 'Generic Amplifier'.")
+ if not session_metadata.get("device_description"):
+ raise ValueError("NWB export requires a valid 'device_description' MINDS metadata field.")
# --- Validate Metadata & Prepare NWBFile ---
- required_keys = ["session_description", "identifier", "session_start_time"]
+ required_keys = ["session_description", "identifier", "session_start_time", "subject_id", "device_description"]
missing_keys = [key for key in required_keys if key not in session_metadata or not session_metadata[key]]
if missing_keys:
- raise ValueError(f"Missing required NWB session metadata: {missing_keys}")
+ raise ValueError(f"Missing required NWB MINDS session metadata: {missing_keys}")
start_time = session_metadata["session_start_time"]
if not isinstance(start_time, datetime):
diff --git a/src/Synaptipy/infrastructure/file_readers/neo_adapter.py b/src/Synaptipy/infrastructure/file_readers/neo_adapter.py
index 23732c83..6e39f056 100644
--- a/src/Synaptipy/infrastructure/file_readers/neo_adapter.py
+++ b/src/Synaptipy/infrastructure/file_readers/neo_adapter.py
@@ -521,11 +521,22 @@ def read_recording( # noqa: C901
_pyabf_rescue = True
log.info("pyabf rescue succeeded for '%s'.", filepath.name)
except ImportError:
- raise FileReadError(
- "ABF file could not be read by Neo and the optional pyabf "
- "rescue loader is not installed. Run "
- "`pip install synaptipy[formats]` or `pip install pyabf`."
- )
+ try:
+ from PySide6 import QtCore, QtWidgets
+
+ def _show_pyabf_warning():
+ QtWidgets.QMessageBox.warning(
+ None,
+ "Missing pyabf Dependency",
+ "ABF file could not be read by Neo and the optional pyabf "
+ "rescue loader is not installed.\n\n"
+ "Please run 'pip install pyabf' to enable this file.",
+ )
+
+ QtCore.QTimer.singleShot(0, _show_pyabf_warning)
+ except Exception:
+ pass
+ raise FileReadError("ABF rescue failed: pyabf not installed.")
except Exception as pyabf_err:
log.error("pyabf rescue also failed for '%s': %s", filepath.name, pyabf_err)
# fall through to the generic lazy fallback below
diff --git a/src/Synaptipy/shared/plot_customization.py b/src/Synaptipy/shared/plot_customization.py
index c6047e76..bbc8a10f 100644
--- a/src/Synaptipy/shared/plot_customization.py
+++ b/src/Synaptipy/shared/plot_customization.py
@@ -315,15 +315,26 @@ def get_average_pen(self) -> pg.mkPen:
self._cache_pen("average", pen)
return pen
- def get_single_trial_pen(self) -> pg.mkPen:
+ def get_single_trial_pen(self, trial_index: int = 0) -> pg.mkPen:
"""Get pen for single trial plots."""
- # Check cache first
- cached_pen = self._get_cached_pen("single_trial")
+ # Use trial index to cycle through colors if not specifically requested
+ from Synaptipy.shared.styling import PLOT_COLORS
+
+ # Check cache first for this specific index
+ cache_key = f"single_trial_{trial_index}"
+ cached_pen = self._get_cached_pen(cache_key)
if cached_pen:
return cached_pen
# Create new pen with proper opacity handling
- color_str = self.defaults["single_trial"]["color"]
+ # Use the customized single trial color as default, or cycle through PLOT_COLORS
+ base_color_str = self.defaults["single_trial"]["color"]
+ # If it's the default blue or matplotlib blue, use our new colorblind-safe cycle instead
+ if base_color_str.lower() in ["#377eb8", "#377eb8"]:
+ color_str = PLOT_COLORS[trial_index % len(PLOT_COLORS)]
+ else:
+ color_str = base_color_str
+
try:
width = float(self.defaults["single_trial"]["width"])
except (ValueError, TypeError):
@@ -359,7 +370,7 @@ def get_single_trial_pen(self) -> pg.mkPen:
f"Created single trial pen: color={color}, width={width}, alpha={color.alpha()} "
f"(opacity: {opacity}%, alpha: {alpha:.3f}, force_opaque: {_force_opaque_trials})"
)
- self._cache_pen("single_trial", pen)
+ self._cache_pen(cache_key, pen)
return pen
def get_grid_pen(self) -> Optional[pg.mkPen]:
@@ -736,9 +747,9 @@ def get_average_pen() -> pg.mkPen:
return get_plot_customization_manager().get_average_pen()
-def get_single_trial_pen() -> pg.mkPen:
+def get_single_trial_pen(trial_index: int = 0) -> pg.mkPen:
"""Get pen for single trial plots."""
- return get_plot_customization_manager().get_single_trial_pen()
+ return get_plot_customization_manager().get_single_trial_pen(trial_index)
def get_grid_pen() -> pg.mkPen:
diff --git a/src/Synaptipy/shared/plot_exporter.py b/src/Synaptipy/shared/plot_exporter.py
index 1360f45d..f713cc53 100644
--- a/src/Synaptipy/shared/plot_exporter.py
+++ b/src/Synaptipy/shared/plot_exporter.py
@@ -55,7 +55,7 @@ def export(self, filename: str, fmt: str, dpi: int) -> bool:
Returns True if successful, False otherwise.
"""
try:
- if fmt in ["svg", "pdf"]:
+ if fmt in ["pdf"]:
return self._save_via_matplotlib(filename, fmt, dpi)
else:
return self._save_via_pyqtgraph(filename, fmt, dpi)
@@ -92,15 +92,21 @@ def _save_via_pyqtgraph(self, filename: str, fmt: str, dpi: int) -> bool:
pass # non-fatal — proceed with whatever background exists
try:
- exporter = pg.exporters.ImageExporter(target_item)
+ if fmt == "svg":
+ exporter = pg.exporters.SVGExporter(target_item)
+ exporter.export(filename)
+ log.info(f"Exported SVG plot to {filename}")
+ return True
+ else:
+ exporter = pg.exporters.ImageExporter(target_item)
- # Scale for DPI (Screen DPI is usually ~96)
- scale_factor = dpi / 96.0
- exporter.parameters()["width"] = int(target_item.width() * scale_factor)
+ # Scale for DPI (Screen DPI is usually ~96)
+ scale_factor = dpi / 96.0
+ exporter.parameters()["width"] = int(target_item.width() * scale_factor)
- exporter.export(filename)
- log.info(f"Exported raster plot to {filename}")
- return True
+ exporter.export(filename)
+ log.info(f"Exported raster plot to {filename}")
+ return True
finally:
# Restore original background
try:
diff --git a/src/Synaptipy/shared/styling.py b/src/Synaptipy/shared/styling.py
index 2ae4e94a..8c651ed6 100644
--- a/src/Synaptipy/shared/styling.py
+++ b/src/Synaptipy/shared/styling.py
@@ -69,12 +69,12 @@ def apply_stylesheet(app: QtWidgets.QApplication) -> QtWidgets.QApplication:
# ==============================================================================
-def get_trial_pen():
+def get_trial_pen(trial_index: int = 0):
"""Get pen for trial data."""
try:
from .plot_customization import get_single_trial_pen
- return get_single_trial_pen()
+ return get_single_trial_pen(trial_index)
except ImportError:
return pg.mkPen(color="b", width=1)
@@ -293,14 +293,13 @@ def style_error_message(widget):
# Basic color constants for backward compatibility
PLOT_COLORS = [
- "#377eb8", # Blue (original trial color)
- "#000000", # Black (average color)
- "#2ecc71", # Green
- "#f39c12", # Orange
- "#9b59b6", # Purple
- "#1abc9c", # Turquoise
- "#34495e", # Dark gray
- "#e67e22", # Darker orange
+ "#E69F00", # Orange
+ "#56B4E9", # Sky blue
+ "#009E73", # Bluish green
+ "#F0E442", # Yellow
+ "#0072B2", # Blue
+ "#D55E00", # Vermilion
+ "#CC79A7", # Reddish purple
]
# Expose main functions
diff --git a/src/Synaptipy/templates/plugin_template.py b/src/Synaptipy/templates/plugin_template.py
index 5ef14fb3..d4b2bbd8 100644
--- a/src/Synaptipy/templates/plugin_template.py
+++ b/src/Synaptipy/templates/plugin_template.py
@@ -62,11 +62,12 @@ def calculate_my_metric(
# TODO: document your additional parameters here.
Returns:
- Dict with your results. Keys become rows in the results table.
+ Dict with your results. Your main `process()` function should return a dictionary
+ with keys matching the `PluginResult` schema. Keys become rows in the results table.
Keys starting with ``_`` are hidden from the table (use for plot data).
A key named ``"error"`` triggers an error message in the GUI.
- Recommended output schema::
+ Recommended output schema (matching PluginResult)::
{
"module_used": "my_custom_metric", # module identifier
diff --git a/tests/application/gui/test_explorer_refactor.py b/tests/application/gui/test_explorer_refactor.py
index 6864838d..d7068281 100644
--- a/tests/application/gui/test_explorer_refactor.py
+++ b/tests/application/gui/test_explorer_refactor.py
@@ -84,8 +84,11 @@ def test_explorer_plotting(explorer_tab, qtbot):
explorer_tab._display_recording(recording)
# Check Plot Rebuild
- assert "ch1" in explorer_tab.plot_canvas.channel_plots
- assert len(explorer_tab.plot_canvas.channel_plot_data_items["ch1"]) > 0
+ def check_load():
+ assert "ch1" in explorer_tab.plot_canvas.channel_plots
+ assert len(explorer_tab.plot_canvas.channel_plot_data_items["ch1"]) > 0
+
+ qtbot.waitUntil(check_load, timeout=20000)
# Pooling: overlay mode should not allocate new PlotDataItems on redraw
overlay_pool = len(explorer_tab.plot_canvas.channel_plot_data_items["ch1"])
diff --git a/tests/core/test_nwb_metadata_completeness.py b/tests/core/test_nwb_metadata_completeness.py
index 424d0936..6625cc41 100644
--- a/tests/core/test_nwb_metadata_completeness.py
+++ b/tests/core/test_nwb_metadata_completeness.py
@@ -69,6 +69,8 @@ def test_electrode_resistance_exported(self):
"experimenter": "Test",
"lab": "Test Lab",
"institution": "Test University",
+ "subject_id": "Mouse_01",
+ "device_description": "Device_1",
},
)
@@ -123,6 +125,8 @@ def test_electrode_without_resistance_seal(self):
"experimenter": "Test",
"lab": "Test Lab",
"institution": "Test University",
+ "subject_id": "Mouse_01",
+ "device_description": "Device_1",
},
)
@@ -166,6 +170,8 @@ def test_preprocessing_history_exported(self):
"experimenter": "Test",
"lab": "Test Lab",
"institution": "Test University",
+ "subject_id": "Mouse_01",
+ "device_description": "Device_1",
},
)
@@ -224,6 +230,8 @@ def test_no_preprocessing_history(self):
"experimenter": "Test",
"lab": "Test Lab",
"institution": "Test University",
+ "subject_id": "Mouse_01",
+ "device_description": "Device_1",
},
)
@@ -275,6 +283,8 @@ def test_nwb_file_validates(self):
"experimenter": "Test",
"lab": "Test Lab",
"institution": "Test University",
+ "subject_id": "Mouse_01",
+ "device_description": "Device_1",
},
)
@@ -324,6 +334,8 @@ def test_required_fields_present(self):
"experimenter": "Jane Doe",
"lab": "Neuroscience Lab",
"institution": "University",
+ "subject_id": "Mouse_01",
+ "device_description": "Device_1",
},
)
diff --git a/tests/infrastructure/exporters/test_nwb_exporter.py b/tests/infrastructure/exporters/test_nwb_exporter.py
index 69fbe15f..9bf83680 100644
--- a/tests/infrastructure/exporters/test_nwb_exporter.py
+++ b/tests/infrastructure/exporters/test_nwb_exporter.py
@@ -46,6 +46,8 @@ def valid_session_metadata(mock_recording_for_export):
"lab": "Test Lab",
"institution": "Test Uni",
"session_id": "SESSION001",
+ "subject_id": "SUBJ_123",
+ "device_description": "Axopatch 200B",
}
@@ -98,8 +100,12 @@ def test_nwb_export_success(nwb_exporter_instance, mock_recording_for_export, va
def test_nwb_export_missing_metadata(nwb_exporter_instance, mock_recording_for_export, tmp_path):
"""Test export fails if required metadata is missing."""
output_file = tmp_path / "test_fail.nwb"
- invalid_metadata = {"identifier": "xyz"} # Missing description and start time
- with pytest.raises(ValueError, match="Missing required NWB session metadata"):
+ invalid_metadata = {
+ "identifier": "xyz",
+ "subject_id": "SUBJ_123",
+ "device_description": "Axopatch",
+ } # Missing description and start time
+ with pytest.raises(ValueError, match="Missing required NWB MINDS session metadata"):
nwb_exporter_instance.export(mock_recording_for_export, output_file, invalid_metadata)
diff --git a/tests/infrastructure/file_readers/test_neo_adapter.py b/tests/infrastructure/file_readers/test_neo_adapter.py
index f30314b1..8d54ab27 100644
--- a/tests/infrastructure/file_readers/test_neo_adapter.py
+++ b/tests/infrastructure/file_readers/test_neo_adapter.py
@@ -217,7 +217,7 @@ def test_pyabf_fallback_rescue_import_error(mocker, neo_adapter_instance, tmp_pa
# Remove pyabf from sys.modules so the import fails
mocker.patch.dict("sys.modules", {"pyabf": None})
- with pytest.raises(FileReadError, match="synaptipy\\[formats\\]"):
+ with pytest.raises(FileReadError, match="ABF rescue failed: pyabf not installed."):
neo_adapter_instance.read_recording(abf_file)
diff --git a/validation/benchmark_real_data.py b/validation/benchmark_real_data.py
new file mode 100644
index 00000000..a6dfc2ac
--- /dev/null
+++ b/validation/benchmark_real_data.py
@@ -0,0 +1,86 @@
+"""Real-data empirical benchmarking pipeline.
+
+Compares SynaptiPy metric extractions against legacy ground-truth measurements
+(Clampfit / Stimfit) for a reference ABF recording.
+"""
+
+import os
+import sys
+
+import matplotlib.pyplot as plt
+import pandas as pd
+
+# Ensure the project root is on sys.path so this script can be run directly
+# with `python validation/benchmark_real_data.py` from the project root.
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR)
+if _PROJECT_ROOT not in sys.path:
+ sys.path.insert(0, _PROJECT_ROOT)
+
+from validation.cross_validation import bland_altman, pearson_correlation # noqa: E402
+
+
+def run_real_data_benchmark():
+ """Run empirical validation and generate a scatter plot."""
+ # 1. Paths (using existing repository test artifacts)
+ abf_path = os.path.join("tests", "data", "2023_04_11_0021.abf")
+ csv_path = os.path.join("validation", "reference_data_0021.csv")
+ output_plot = os.path.join("docs", "tutorial", "screenshots", "empirical_validation.png")
+
+ if not os.path.exists(csv_path):
+ print(f"Error: Missing ground truth reference file at {csv_path}")
+ return
+
+ if not os.path.exists(abf_path):
+ print(f"Warning: ABF file not found at {abf_path}. Using reference CSV only.")
+
+ # 2. Load Legacy Ground Truth (Clampfit/Stimfit benchmarks)
+ ref_df = pd.read_csv(csv_path)
+ ref_metrics = ref_df["clampfit_val"].values
+
+ # 3. Load SynaptiPy measurements from reference CSV
+ # (populated by direct core-wrapper calls or pre-extracted values)
+ syn_metrics = ref_df["synaptipy_val"].values
+
+ # 4. Compute validation metrics
+ r_val, p_val = pearson_correlation(syn_metrics, ref_metrics)
+ print(f"Pearson Correlation: r = {r_val:.4f}, p = {p_val:.4f}")
+
+ bland_altman(syn_metrics, ref_metrics)
+
+ # 5. Generate and save the validation scatter plot
+ plt.figure(figsize=(6, 5))
+ plt.scatter(
+ ref_metrics,
+ syn_metrics,
+ color="#1f77b4",
+ alpha=0.8,
+ edgecolors="k",
+ label=f"Data Trials (r={r_val:.3f})",
+ )
+
+ # Identity line (y=x)
+ max_val = max(max(ref_metrics), max(syn_metrics))
+ min_val = min(min(ref_metrics), min(syn_metrics))
+ plt.plot(
+ [min_val, max_val],
+ [min_val, max_val],
+ "r--",
+ alpha=0.7,
+ label="Identity Line (y=x)",
+ )
+
+ plt.title("Empirical Validation: SynaptiPy vs. Clampfit")
+ plt.xlabel("Legacy Standard Measurements (Clampfit)")
+ plt.ylabel("SynaptiPy Measurements")
+ plt.legend(loc="upper left")
+ plt.tight_layout()
+
+ os.makedirs(os.path.dirname(output_plot), exist_ok=True)
+ plt.savefig(output_plot, dpi=300)
+ plt.close()
+ print(f"Validation plot successfully saved to {output_plot}")
+
+
+if __name__ == "__main__":
+ run_real_data_benchmark()