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 ``` -![Synaptipy Explorer — multi-trial current-clamp recording with action potentials](https://raw.githubusercontent.com/anzalks/synaptipy/main/docs/tutorial/screenshots/explorer_tab.png) +![Synaptipy Explorer - multi-trial current-clamp recording with action potentials](https://raw.githubusercontent.com/anzalks/synaptipy/main/docs/tutorial/screenshots/explorer_tab.png) 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()