# Writing Custom Analysis Plugins for Synaptipy

This guide explains how to add your own analysis function to Synaptipy as a new
tab in the Analyser - **without modifying any Synaptipy source code**.  You write
a single Python file, drop it in a folder, and your analysis appears in the GUI
and batch engine the next time the application starts.

---

## Table of Contents

1. [Overview - How the Plugin System Works](#1-overview--how-the-plugin-system-works)
2. [Quick Start - Your First Plugin in 5 Minutes](#2-quick-start--your-first-plugin-in-5-minutes)
3. [The Plugin File - Anatomy of a Custom Analysis](#3-the-plugin-file--anatomy-of-a-custom-analysis)
   - [3.1 Part 1: Pure Analysis Logic](#31-part-1-pure-analysis-logic)
   - [3.2 Part 2: Registry Wrapper](#32-part-2-registry-wrapper)
   - [3.3 Return Dict Conventions](#33-return-dict-conventions)
4. [Defining GUI Parameters (`ui_params`)](#4-defining-gui-parameters-ui_params)
   - [4.1 `float` Parameter](#41-float-parameter)
   - [4.2 `int` Parameter](#42-int-parameter)
   - [4.3 `choice` / `combo` Parameter](#43-choice--combo-parameter)
   - [4.4 `bool` Parameter](#44-bool-parameter)
   - [4.5 `string` Parameter](#45-string-parameter)
   - [4.6 `filepath` Parameter (with Browse button)](#46-filepath-parameter-with-browse-button)
   - [4.7 `dirpath` Parameter (with Browse button)](#47-dirpath-parameter-with-browse-button)
   - [4.8 Common Optional Fields](#48-common-optional-fields)
   - [4.9 Conditional Visibility (`visible_when`)](#49-conditional-visibility-visible_when)
5. [Defining Plot Overlays (`plots`)](#5-defining-plot-overlays-plots)
   - [5.1 `hlines` - Horizontal Lines](#51-hlines--horizontal-lines)
   - [5.2 `vlines` - Vertical Lines](#52-vlines--vertical-lines)
   - [5.3 `markers` - Scatter Points](#53-markers--scatter-points)
   - [5.4 `interactive_region` - Draggable Region](#54-interactive_region--draggable-region)
   - [5.5 `threshold_line` - Draggable Threshold](#55-threshold_line--draggable-threshold)
   - [5.6 `overlay_fit` - Curve Overlay](#56-overlay_fit--curve-overlay)
   - [5.7 `popup_xy` - Popup Scatter/Line Plot](#57-popup_xy--popup-scatterline-plot)
   - [5.8 `brackets` - Burst/Event Brackets](#58-brackets--burstevent-brackets)
   - [5.9 `event_markers` - Interactive Event Points](#59-event_markers--interactive-event-points)
   - [5.10 `trace` - Base Trace with Overlay](#510-trace--base-trace-with-overlay)
   - [5.11 `fill_between` - Shaded Region Between Two Curves](#511-fill_between--shaded-region-between-two-curves)
6. [Where to Put Your Plugin File](#6-where-to-put-your-plugin-file)
7. [For Core Contributors - Adding a Built-in Analysis](#7-for-core-contributors--adding-a-built-in-analysis)
8. [Testing Your Plugin](#8-testing-your-plugin)
9. [Full Annotated Example - Synaptic Charge Transfer](#9-full-annotated-example--synaptic-charge-transfer)
10. [Troubleshooting](#10-troubleshooting)
11. [SpikeInterface Integration Plugin](#11-spikeinterface-integration-plugin)
12. [Deep Learning & Third-Party Integrations (e.g., miniML)](#12-deep-learning--third-party-integrations-eg-miniml)
13. [Troubleshooting (full table)](#13-troubleshooting)

---

## 1. Overview - How the Plugin System Works

Synaptipy has a central **`AnalysisRegistry`** - a Python class that maps named
analysis functions to the GUI and batch engine.  You register a function by
decorating it with `@AnalysisRegistry.register(...)`.  The decorator stores the
function and its metadata (parameter definitions, plot overlays, label, etc.).

At startup, Synaptipy:

1. Loads all **built-in** analyses from `src/Synaptipy/core/analysis/`.
2. Scans **two** plugin directories in order:
   - `examples/plugins/` inside the Synaptipy installation - shipped example
     plugins that work out-of-the-box without any extra setup.
   - `~/.synaptipy/plugins/` - your personal or third-party additions.
   If a file with the same stem name exists in both directories, the user copy
   takes precedence and a warning is written to the log.
3. Builds the Analyser GUI.  For every registered analysis that does *not*
   already have a hand-coded tab class, a **metadata-driven tab is created
   automatically** - complete with parameter widgets, a Run button, a results
   table, and plot overlays.  Your function appears as a new sub-tab.

```
┌──────────────────────────────────────────────────────────┐
│  ~/.synaptipy/plugins/synaptic_charge.py                 │
│                                                          │
│  @AnalysisRegistry.register(                             │
│      name="synaptic_charge",                             │
│      label="Synaptic Charge (AUC)",                      │
│      ui_params=[...],                                    │
│      plots=[...]                                         │
│  )                                                       │
│  def run_auc(data, time, sampling_rate, **kwargs):        │
│      ...                                                 │
│      return {"module_used": "synaptic_charge",           │
│              "metrics": {"Charge_pC": 1.23,              │
│                          "Baseline_pA": -42.0}}          │
└──────────────────────────────────────────────────────────┘
         │
         ▼ startup → PluginManager.load_plugins()
┌──────────────────────────────────────────────────────────┐
│  AnalysisRegistry                                        │
│  ├── rmp_analysis          (built-in)                    │
│  ├── spike_detection       (built-in)                    │
│  ├── ...                                                 │
│  └── synaptic_charge       ← YOUR PLUGIN                 │
└──────────────────────────────────────────────────────────┘
         │
         ▼ GUI build → auto-generated MetadataDrivenAnalysisTab
┌──────────────────────────────────────────────────────────┐
│  Analyser Tab:  ... | Baseline | Spikes |                │
│                     Synaptic Charge (AUC) ◄───────────  │
│  ┌────────────────────────────────────────────┐          │
│  │ Baseline Start (s):    [0.0        ]       │          │
│  │ Baseline End (s):      [0.05       ]       │          │
│  │ Window Start (s):      [0.05       ]       │          │
│  │ Window End (s):        [0.3        ]       │          │
│  │           [ ▶ Run Analysis ]               │          │
│  │ ──────────────────────────────────────      │          │
│  │ Results: Charge = 1.23 pC                  │          │
│  └────────────────────────────────────────────┘          │
└──────────────────────────────────────────────────────────┘
```

**You do not need to write any GUI code.** The `ui_params` list generates the
parameter widgets, and the `plots` list generates the plot overlays - all from
metadata.

---

## 2. Quick Start - Your First Plugin in 5 Minutes

1. Copy the template:
   ```bash
   # macOS / Linux
   cp src/Synaptipy/templates/plugin_template.py ~/.synaptipy/plugins/my_analysis.py

   # Windows (PowerShell)
   Copy-Item src\Synaptipy\templates\plugin_template.py ~\.synaptipy\plugins\my_analysis.py
   ```
   The plugin directory is created automatically the first time Synaptipy runs.
   If it does not exist yet, create it manually:
   - macOS / Linux: `mkdir -p ~/.synaptipy/plugins`
   - Windows (PowerShell): `New-Item -ItemType Directory -Force "$HOME\.synaptipy\plugins"`

   > **Where is the plugins folder?**
   >
   > | Platform | Path |
   > |----------|------|
   > | macOS / Linux | `~/.synaptipy/plugins/` |
   > | Windows | `C:\Users\<YourUsername>\.synaptipy\plugins\` |
   >
   > On Windows you can open it by typing `%USERPROFILE%\.synaptipy\plugins`
   > in the File Explorer address bar.

2. Open the copied file in any editor.

3. Rename the function, change the `name=` and `label=`, adjust the `ui_params`
   and `plots` lists, and write your analysis logic.

4. (Re)start Synaptipy.  Your analysis appears as a new tab in the Analyser.

No `pip install`, no editing `__init__.py`, no rebuilding - just save and restart.

---

## 3. The Plugin File - Anatomy of a Custom Analysis

A plugin file has two parts:

### 3.1 Part 1: Pure Analysis Logic

A regular Python function with explicit typed arguments and a return value.
No GUI dependencies (no PySide6, no pyqtgraph).

```python
import numpy as np

def calculate_area_under_curve(
    data: np.ndarray,
    time: np.ndarray,
    sampling_rate: float,
    baseline_start: float,
    baseline_end: float,
    window_start: float,
    window_end: float,
) -> dict:
    """Integrate baseline-subtracted trace to obtain synaptic charge (pC)."""
    bl0 = int(np.searchsorted(time, baseline_start, side="left"))
    bl1 = int(np.searchsorted(time, baseline_end,   side="right"))
    baseline = float(np.mean(data[bl0:bl1])) if bl1 > bl0 else 0.0

    wi0 = int(np.searchsorted(time, window_start, side="left"))
    wi1 = int(np.searchsorted(time, window_end,   side="right"))
    win_t = time[wi0:wi1]
    win_d = data[wi0:wi1] - baseline

    if win_t.size < 2:
        return {"error": "Integration window too narrow"}

    charge_pc = float(np.trapezoid(win_d, win_t))
    return {
        "Charge_pC":   round(charge_pc, 6),
        "Baseline_pA": round(baseline,  4),
        "_int_x": win_t,
        "_int_y": data[wi0:wi1],
        "_base":  baseline,
    }
```

**Rules for the logic function:**

- Takes explicit, typed arguments - no `**kwargs`.
- Returns a typed result object or a plain dict.
- Must handle edge cases (empty data, bad windows) gracefully.
- Must not import any GUI modules.

### 3.2 Part 2: Registry Wrapper

A thin wrapper decorated with `@AnalysisRegistry.register(...)`.  It extracts
parameters from `kwargs`, calls your logic function, and returns a dict that
follows the **nested output schema**.

```python
from Synaptipy.core.analysis.registry import AnalysisRegistry

@AnalysisRegistry.register(
    name="synaptic_charge",              # unique internal name
    label="Synaptic Charge (AUC)",       # display name in the tab
    expects_list=False,                  # True only for multi-trial statistics
    ui_params=[...],                     # parameter widgets (see §4)
    plots=[...],                         # plot overlays (see §5)
)
def run_auc_wrapper(
    data: np.ndarray,
    time: np.ndarray,
    sampling_rate: float,
    **kwargs,
) -> dict:
    """Registry wrapper for AUC / synaptic charge analysis."""
    return calculate_area_under_curve(
        data=data,
        time=time,
        sampling_rate=sampling_rate,
        baseline_start=kwargs.get("baseline_start", 0.0),
        baseline_end=kwargs.get("baseline_end",     0.05),
        window_start=kwargs.get("window_start",     0.05),
        window_end=kwargs.get("window_end",         0.3),
    )
```

**The wrapper signature is fixed:**

```python
def wrapper(data: np.ndarray, time: np.ndarray, sampling_rate: float, **kwargs) -> Dict[str, Any]
```

| Parameter | Type | Description |
|---|---|---|
| `data` | `np.ndarray` | 1-D voltage/current trace for the selected sweep |
| `time` | `np.ndarray` | 1-D time array in seconds, same length as `data` |
| `sampling_rate` | `float` | Sampling rate in Hz |
| `**kwargs` | | All `ui_params` values, keyed by their `"name"` field |

### 3.3 Return Dict Conventions

Wrappers **must** return a `Dict[str, Any]` using the nested output schema:

```python
{
    "module_used": "my_plugin_name",   # string identifying the source module
    "metrics": {                       # all scalar results go here
        "MyMetric1": 1.0,
        "MyMetric2": 42,
    },
    # optional private keys for plot overlays (hidden from results table):
    "_fit_curve": np.array(...),
    "_event_indices": [...],
}
```

The `metrics` dict drives the results table and batch CSV columns.  Any key
in `metrics` appears as a column header; any value that is a number is
written to the CSV.

| Convention | Behaviour |
|---|---|
| `"module_used"` | Identifies the source; used by the batch engine to route results to the correct CSV file. |
| Keys inside `"metrics"` | Displayed in the results table and exported to CSV.  Use plain, human-readable names. |
| Keys starting with `_` at the **top level** | **Hidden** from the results table.  Use for arrays passed to plot overlays (e.g. `"_fit_curve"`, `"_event_indices"`). |
| Key named `"error"` at the top level | If present, the GUI shows an error message instead of results. |
| Numeric values in `metrics` (`int`, `float`) | Displayed as-is in the results table. |
| `np.ndarray` values in `metrics` | Displayed as shape summary (e.g. `"array(150,)"`). |
| `None` values in `metrics` | Displayed as `"N/A"`. |

---

### 3.3 The `expects_list` Parameter

The `expects_list` keyword in `@AnalysisRegistry.register(...)` controls how
the **batch engine** delivers data to your wrapper function.

| Value | Data passed to wrapper | When to use |
|---|---|---|
| `False` (default) | A single 1-D `np.ndarray` - one trial or a pre-averaged master trace | Almost always - single-sweep metrics (charge, peak amplitude, tau, etc.) |
| `True` | A Python `list` of per-trial `np.ndarray` objects | Only when you explicitly need trial-to-trial comparisons (jitter, variance, reliability) |

**Why this matters in batch mode:**  when the batch scope is `"all_trials"` and
`expects_list=False`, the batch engine automatically averages all trials into a
single array before calling your function.  This prevents
``TRIAL_LENGTH_MISMATCH`` errors in recordings where different sweeps have
slightly different lengths (e.g. ABF files with truncated last sweeps).

```python
# Plugin that needs a list of trials (jitter / variance calculation)
@AnalysisRegistry.register(
    name="latency_jitter",
    label="Latency Jitter",
    expects_list=True,     # <-- batch engine passes list[np.ndarray]
    ui_params=[...],
)
def run_jitter(data, time, sampling_rate, **kwargs):
    # data is a list of 1-D arrays, one per trial
    latencies = [find_first_spike(d, ...) for d in data]
    return {"module_used": "latency_jitter", "metrics": {"Jitter_ms": np.std(latencies)}}


# Plugin that operates on a single trace (most plugins)
@AnalysisRegistry.register(
    name="synaptic_charge",
    label="Synaptic Charge (AUC)",
    expects_list=False,    # <-- batch engine pre-averages if needed (default)
    ui_params=[...],
)
def run_charge(data, time, sampling_rate, **kwargs):
    # data is a single 1-D array
    return calculate_area_under_curve(data, time, ...)
```

---

## 4. Defining GUI Parameters (`ui_params`)

The `ui_params` list in `@AnalysisRegistry.register(...)` defines the parameter
widgets that appear in your tab.  Each entry is a dict describing one widget.

### 4.1 `float` Parameter

Creates a double-precision spin box.

```python
{
    "name": "threshold",          # kwarg name passed to your wrapper
    "label": "Threshold (mV):",   # label text shown in the GUI
    "type": "float",
    "default": -20.0,             # initial value (default: 0.0)
    "min": -200.0,                # minimum allowed (default: -1e9)
    "max": 200.0,                 # maximum allowed (default: 1e9)
    "decimals": 2,                # decimal places (default: 4)
    "step": 0.5,                  # step increment (optional)
}
```

### 4.2 `int` Parameter

Creates an integer spin box.

```python
{
    "name": "min_spikes",
    "label": "Min Spikes:",
    "type": "int",
    "default": 3,
    "min": 1,
    "max": 10000,
}
```

### 4.3 `choice` / `combo` Parameter

Creates a drop-down combo box.

```python
{
    "name": "direction",
    "label": "Detection Direction:",
    "type": "choice",             # "combo" also works
    "choices": ["negative", "positive", "both"],
    "default": "negative",        # pre-selected option
}
```

> **Note:** You can use `"options"` instead of `"choices"` - both keys are accepted.

### 4.4 `bool` Parameter

Creates a check box.

```python
{
    "name": "auto_detect",
    "label": "Auto-Detect Baseline",
    "type": "bool",
    "default": False,
}
```

### 4.5 `string` Parameter

Creates a plain text entry field (`QLineEdit`).  Use this for free-form strings
such as user labels or non-path text values.

```python
{
    "name": "cell_label",
    "label": "Cell Label:",
    "type": "string",           # aliases: "str", "path"
    "default": "",
    "placeholder": "e.g. CA1 pyramidal",
    "tooltip": "Optional label stored in the results table.",
}
```

### 4.6 `filepath` Parameter (with Browse button)

Creates a `QLineEdit` + **Browse...** button that opens a file-picker dialog.
The dialog is pre-filtered to `*.h5 *.keras` model files; all files are also
accessible.  The chosen path is written back into the text field.

Use this for parameters that require an absolute path to a single file
(e.g. a pre-trained model).

```python
{
    "name": "model_path",
    "label": "Model Path (.h5):",
    "type": "filepath",
    "default": "",
    "placeholder": "/path/to/model.h5",
    "tooltip": "Absolute path to the Keras model file.",
}
```

### 4.7 `dirpath` Parameter (with Browse button)

Like `filepath` but opens a directory-picker dialog.  Use this when the plugin
needs a directory to be on `sys.path` (e.g. a source-only library like miniML).

```python
{
    "name": "miniml_core_path",
    "label": "miniML core/ Path:",
    "type": "dirpath",
    "default": "",
    "placeholder": "/path/to/miniML/core",
    "tooltip": "Folder containing miniML.py (click Browse to select).",
}
```

### 4.8 Common Optional Fields

These fields work on any parameter type:

| Field | Type | Description |
|---|---|---|
| `"tooltip"` | `str` | Tooltip text shown on hover. |
| `"hidden"` | `bool` | If `True`, the widget is not created at all.  Use for internal params that should not be user-editable. |
| `"visible_when"` | `dict` | Conditional visibility - see below. |

### 4.9 Conditional Visibility (`visible_when`)

Show or hide a parameter widget based on the current value of another widget.

**Example:** Show `"spike_threshold"` only when `"event_type"` is set to
`"Spikes"`:

```python
ui_params=[
    {"name": "event_type", "type": "choice", "choices": ["Spikes", "EPSCs"],
     "label": "Event Type:", "default": "Spikes"},

    {"name": "spike_threshold", "type": "float", "default": -20.0,
     "label": "Spike Threshold (mV):",
     "visible_when": {"param": "event_type", "value": "Spikes"}},

    {"name": "epsc_threshold", "type": "float", "default": -5.0,
     "label": "EPSC Threshold (pA):",
     "visible_when": {"param": "event_type", "value": "EPSCs"}},
]
```

The `"param"` key names the sibling widget; `"value"` is the value that makes
this widget visible.  When the controlling widget changes, visibility updates
automatically.

There is also a `"context"` form for clamp-mode-aware parameters:

```python
"visible_when": {"context": "clamp_mode", "value": "current_clamp"}
```

---

## 5. Defining Plot Overlays (`plots`)

The `plots` list in `@AnalysisRegistry.register(...)` defines visual overlays
rendered on the data trace after analysis completes.  Each entry is a dict.

### 5.1 `hlines` - Horizontal Lines

Draw horizontal lines at y-positions taken from the result dict.

```python
{"type": "hlines", "data": ["threshold"], "color": "r", "styles": ["dash"]}
{"type": "hlines", "data": ["mean", "mean_plus_sd", "mean_minus_sd"],
 "color": "b", "styles": ["solid", "dash", "dash"]}
```

| Field | Description |
|---|---|
| `data` | List of result-dict keys.  Each key's value → one horizontal line. |
| `color` | Line colour (default `"r"`). |
| `styles` | List of `"solid"` or `"dash"`, one per key (default: all `"solid"`). |

### 5.2 `vlines` - Vertical Lines

Draw vertical lines at x-positions.

```python
{"type": "vlines", "data": "stimulus_times", "color": "c"}
```

| Field | Description |
|---|---|
| `data` | Result-dict key holding a scalar or array of x-positions. |
| `color` | Line colour (default `"b"`). |

### 5.3 `markers` - Scatter Points

Draw scatter points at (x, y) positions from result arrays.

```python
{"type": "markers", "x": "peak_times", "y": "peak_values", "color": "r"}
```

### 5.4 `interactive_region` - Draggable Region

A shaded region linked to two float spinboxes.  Dragging the region updates the
spinbox values, and changing a spinbox moves the region.

```python
{"type": "interactive_region", "data": ["window_start", "window_end"], "color": "g"}
```

`"data"` must be a 2-element list of `ui_params` names (not result keys).

### 5.5 `threshold_line` - Draggable Threshold

A horizontal line synced to a float parameter widget.

```python
{"type": "threshold_line", "param": "threshold"}
```

### 5.6 `overlay_fit` - Curve Overlay

Overlay a fitted curve on the trace.

```python
{
    "type": "overlay_fit",
    "x": "_fit_time",           # result key (use _ prefix to hide from table)
    "y": "_fit_values",
    "color": "r",
    "width": 2,
    "label": "Exponential Fit",
}
```

### 5.7 `popup_xy` - Popup Scatter/Line Plot

Open a separate window showing e.g. an I-V or F-I curve.

```python
{
    "type": "popup_xy",
    "title": "F-I Curve",
    "x": "current_steps_pa",
    "y": "firing_rates_hz",
    "x_label": "Current (pA)",
    "y_label": "Firing Rate (Hz)",
}
```

Optionally add `"slope_key"` and `"intercept_key"` for a regression line.

### 5.8 `brackets` - Burst/Event Brackets

Draw bracket bars above burst groups.

```python
{"type": "brackets", "data": "bursts", "color": "r"}
```

`"data"` key should hold a list of arrays (each array = spike times within one
burst).

### 5.9 `event_markers` - Interactive Event Points

Scatter plot with click-to-remove and Ctrl+click-to-add.

```python
{"type": "event_markers"}
```

Reads `result["event_indices"]` automatically.

### 5.10 `trace` - Base Trace with Overlay

Plot the trace with optional spike/event markers.

```python
{"type": "trace", "show_spikes": True}
```

### 5.11 `fill_between` - Shaded Region Between Two Curves

Draw a translucent filled area between a primary curve (`y1`) and a baseline
curve or constant (`y2`).  This is ideal for visualising integrated areas such
as synaptic charge transfer.

```python
{
    "type": "fill_between",
    "x": "_int_x",        # key for the shared time array
    "y1": "_int_y",       # key for the upper/primary curve (required)
    "y2": "_base",        # key for the lower curve or scalar baseline (default: 0.0)
    "brush": (0, 100, 255, 100),  # RGBA fill colour (optional)
}
```

The named keys are looked up first in the top-level result dict and then inside
the nested `result["metrics"]` dict, so both a flat schema and the standard
`{"module_used": ..., "metrics": {...}}` schema are supported transparently.

`y2` may be:
- a key pointing to an **array** of the same length as `y1` (arbitrary curve),
- a key pointing to a **scalar** (constant horizontal baseline), or
- omitted entirely (defaults to zero).

**Example - Synaptic Charge Transfer with shaded integral:**

```python
plots=[
    {"type": "interactive_region", "data": ["window_start", "window_end"], "color": "g"},
    {
        "type": "fill_between",
        "x": "_int_x",
        "y1": "_int_y",
        "y2": "_base",
        "brush": (0, 100, 255, 100),
    },
]
```

The corresponding return dict must include `_int_x`, `_int_y`, and `_base` as
private (hidden) keys:

```python
return {
    "module_used": "synaptic_charge",
    "metrics": {"Total_Charge_pC": charge},
    "Total_Charge_pC": charge,
    "_int_x": win_time.tolist(),
    "_int_y": win_data.tolist(),
    "_base": baseline_mean,
}
```

### 5.12 `trace_overlay` - Highlight a Region on the Raw Trace

Draw a semi-transparent coloured segment directly on top of the raw trace to
highlight an analysed time window (e.g. a baseline region or a response
integration window).

```python
{
    "type": "trace_overlay",
    "start_time": "_baseline_start_s",   # result key for start time (s)
    "end_time": "_baseline_end_s",       # result key for end time (s)
    "color": "#00cfff",                  # hex or name (default from preferences)
    "width": 3,                          # pen width in pixels
    "opacity": 60,                       # 0-100%; 100 = fully opaque
}
```

| Field | Type | Description |
|---|---|---|
| `start_time` | str or float | Result key or literal float giving the region start (s). |
| `end_time` | str or float | Result key or literal float giving the region end (s). |
| `color` | str | Colour string or hex code.  Falls back to the **Trace Overlay** preference if omitted. |
| `width` | int | Pen width (pixels, default 3). |
| `opacity` | int | 0-100 (default 60). Can be overridden by the user via **Plot Preferences > Trace Overlay**. |

The overlay only renders when the time region lies within the currently plotted
time axis, and uses `self._current_plot_data` to obtain the raw trace samples.

### 5.13 `event_fit_overlay` - Overlay Fitted Event Decay Curves

Plot fitted curves (e.g. bi-exponential EPSP decays) hugging each detected
event on the raw trace.  Supports both single-event (1-D arrays) and
multi-event (list of arrays) data.

```python
{
    "type": "event_fit_overlay",
    "times_key": "_event_fit_times",    # result key for fit time array(s)
    "values_key": "_event_fit_values",  # result key for fit value array(s)
    "color": "#ff9900",                 # amber by default
    "width": 2,
    "opacity": 80,
}
```

| Field | Type | Description |
|---|---|---|
| `times_key` | str | Result key for fit time array (1-D `ndarray`) or list of arrays (one per event). |
| `values_key` | str | Result key for fit value array or list of arrays. |
| `color` | str | Colour (falls back to **Event Fit Overlay** preference). |
| `width` | int | Pen width (default 2). |
| `opacity` | int | 0-100 (default 80). |

**Example return dict** for a PPR decay fit:

```python
return {
    "module_used": "evoked_responses",
    "metrics": {
        "decay_tau_ms": tau_ms,
    },
    # Private keys at the TOP LEVEL (not inside "metrics") are fed to plot
    # overlays and hidden from the results table.
    "_ppr_fit_times": fit_time_array.tolist(),   # absolute time (s)
    "_ppr_fit_values": fit_value_array.tolist(),  # fitted current/voltage
}
```

The user can customise both overlay types via
**Edit > Plot Preferences > Trace Overlay** and **Event Fit Overlay** tabs.

---

## 6. Where to Put Your Plugin File

> **Prerequisite - Enable Custom Plugins:** Before your plugin will load you
> must ensure the "Enable Custom Plugins" checkbox is checked in
> **Edit > Preferences > Extensions** (or **Synaptipy > Preferences** on
> macOS).  This setting is on by default.  After changing it, restart
> Synaptipy for the change to take effect.

### Real-world templates in `examples/plugins/`

> **Looking for a working starting point?**  The `examples/plugins/` directory
> ships three fully annotated, copy-pasteable plugin templates that cover the
> most common use cases:
>
> | File | What it demonstrates |
> |---|---|
> | `synaptic_charge.py` | Baseline subtraction, trapezoidal integration, `fill_between` + star overlays |
> | `opto_jitter.py` | Multi-channel access (TTL + voltage), per-trial loop, jitter statistics |
> | `ap_repolarization.py` | Derivative-based detection, `vlines` + `hlines` overlays |
>
> Copy any file to `~/.synaptipy/plugins/`, rename the function and the
> `name=` / `label=` fields in the decorator, and you have a working plugin
> in minutes -- no blank-page problem.

### Option A: Built-in Examples Directory

Synaptipy ships ready-to-run example plugins in `examples/plugins/`.  These are
loaded automatically at startup so you can try them immediately and use them as
templates.  Enable them via **Edit > Preferences** (or **Synaptipy > Preferences**
on macOS) by checking **Enable Custom Plugins**, then restart Synaptipy.

---

#### Included Example Plugins

| File | Label in GUI | Purpose |
|------|-------------|---------|
| `examples/plugins/synaptic_charge.py` | Synaptic Charge (AUC) | Integrates a postsynaptic current trace over a user-defined window to compute total charge (pC) via the trapezoidal rule; highlights the integrated area with a shaded fill overlay and marks the peak amplitude with a star symbol. |
| `examples/plugins/opto_jitter.py` | Opto Latency Jitter | Detects the first spike in each sweep following a TTL pulse and reports trial-to-trial latency variability (jitter) for optogenetic monosynaptic verification. Requires a secondary digital/TTL channel. |
| `examples/plugins/ap_repolarization.py` | AP Repolarization Rate | Finds the steepest falling slope (dV/dt minimum) of the first action potential in a window, quantifying maximum repolarization rate in V/s as a proxy for potassium-channel dynamics. |

To use these plugins:

1. Open **Edit > Preferences** (or **Synaptipy > Preferences** on macOS).
2. Check **Enable Custom Plugins**.
3. Restart Synaptipy.  Each plugin appears as a new sub-tab in the Analyser.

To customise one, copy the file to `~/.synaptipy/plugins/` and edit your copy.  Synaptipy prefers the user copy over the bundled example, so your changes take effect immediately on the next restart.

### Option B: User Plugin Directory (recommended for personal additions)

| Platform | Full path |
|----------|----------|
| macOS / Linux | `~/.synaptipy/plugins/my_analysis.py` |
| Windows | `C:\Users\<YourUsername>\.synaptipy\plugins\my_analysis.py` |

- No Synaptipy source changes needed.
- File is auto-discovered at startup.
- Works for any number of `.py` files.
- Will not be overwritten by upgrades.
- On Windows, open the folder with `%USERPROFILE%\.synaptipy\plugins` in Explorer.

### Option C: Built-in Module (for core contributors)

If you are contributing to the Synaptipy repository itself:

1. Create your module in `src/Synaptipy/core/analysis/my_analysis.py`.
2. Add the import to `src/Synaptipy/core/analysis/__init__.py`:
   ```python
   from . import my_analysis  # noqa: F401 - registers: my_analysis_name
   ```
3. (Optional) Create a custom tab class in
   `src/Synaptipy/application/gui/analysis_tabs/` if you need GUI behaviour
   beyond what the metadata-driven tab provides.
4. Add a test in `tests/core/test_my_analysis.py`.

> **Important:** The `__init__.py` import is required.  Without it, the
> `@AnalysisRegistry.register` decorator never executes and your analysis will
> not appear (see the developer guide's *Registry import rule*).

---

## 7. For Core Contributors - Adding a Built-in Analysis

Step-by-step:

| Step | File | What to do |
|---|---|---|
| 1 | `src/Synaptipy/core/analysis/my_module.py` | Write pure logic + registry wrapper (see §3) |
| 2 | `src/Synaptipy/core/analysis/__init__.py` | Add `from . import my_module  # noqa: F401` |
| 3 | `tests/core/test_my_module.py` | Write pytest tests for the pure logic function |
| 4 | `tests/core/test_registry_metadata.py` | Add your `name` to `EXPECTED_BUILTIN_ANALYSES` |

**Do not create a custom tab class** unless you need interactive GUI features
(e.g. click-to-add events, drag-to-select spikes) that the metadata-driven tab
cannot provide.

---

## 8. Testing Your Plugin

### Unit-testing the logic function

Since the logic function is pure Python + NumPy, test it directly with pytest:

```python
# test_my_auc.py
import numpy as np
from my_analysis import calculate_area_under_curve

def test_auc_basic():
    fs = 10000.0
    t = np.arange(0, 0.5, 1 / fs)
    # Flat baseline followed by a rectangular current pulse
    data = np.zeros_like(t)
    data[(t >= 0.1) & (t < 0.3)] = -100.0  # 100 pA inward current, 200 ms

    result = calculate_area_under_curve(data, t, fs,
                                        baseline_start=0.0, baseline_end=0.05,
                                        window_start=0.1,   window_end=0.3)
    # Expected charge: -100 pA * 0.2 s = -20 pC (trapezoid should be close)
    assert abs(result["Charge_pC"] - (-20.0)) < 0.01
    assert result["Baseline_pA"] == 0.0

def test_auc_error_on_narrow_window():
    fs = 1000.0
    t = np.array([0.0, 0.001])
    data = np.array([0.0, -1.0])
    result = calculate_area_under_curve(data, t, fs,
                                        baseline_start=0.0, baseline_end=0.001,
                                        window_start=0.5,   window_end=0.6)
    assert "error" in result
```

### Integration-testing the registry wrapper

```python
import numpy as np
from Synaptipy.core.analysis.registry import AnalysisRegistry

def test_auc_registered():
    # Ensure the plugin is loaded
    from Synaptipy.application.plugin_manager import PluginManager
    PluginManager.load_plugins()

    func = AnalysisRegistry.get_function("synaptic_charge")
    assert func is not None

    meta = AnalysisRegistry.get_metadata("synaptic_charge")
    assert "ui_params" in meta
    assert meta.get("label") == "Synaptic Charge (AUC)"
```

### Testing the template shipped with Synaptipy

The repository includes a test that validates the plugin template itself:

```bash
conda run -n synaptipy python -m pytest tests/core/test_plugin_template.py -v
```

---

## 9. Full Annotated Example - Synaptic Charge Transfer

This example measures the **total synaptic charge** ($Q$, in picocoulombs)
delivered during a postsynaptic current by integrating the current trace inside
a user-defined time window using the trapezoidal rule.

$$Q = \int_{t_1}^{t_2} I(t)\, dt$$

For a current trace in pA and time in seconds the result is in pA·s = pC.

Save this as `~/.synaptipy/plugins/synaptic_charge.py` (or copy it from
`examples/plugins/`):

```python
"""
Custom Synaptipy Plugin: Synaptic Charge Transfer (Area Under Curve).

Drop this file in ~/.synaptipy/plugins/ and restart Synaptipy.
A new "Synaptic Charge Transfer" tab will appear in the Analyser.
"""
import logging
from typing import Any, Dict

import numpy as np

from Synaptipy.core.analysis.registry import AnalysisRegistry

log = logging.getLogger(__name__)


# ── Part 1: Pure logic ─────────────────────────────────────────────
def calculate_synaptic_charge(
    data: np.ndarray,
    time: np.ndarray,
    sampling_rate: float,
    window_start: float,
    window_end: float,
    baseline_start: float,
    baseline_end: float,
) -> Dict[str, Any]:
    """
    Integrate the baseline-subtracted current trace to obtain total charge.

    Args:
        data: 1-D current trace in pA.
        time: 1-D time array in seconds, same length as data.
        sampling_rate: Sampling rate in Hz.
        window_start: Start of the integration window (seconds).
        window_end: End of the integration window (seconds).
        baseline_start: Start of the baseline window (seconds).
        baseline_end: End of the baseline window (seconds).

    Returns:
        Dict with 'Total_Charge_pC' and hidden keys for plot overlays.
        Returns {'error': ...} on invalid input.
    """
    if data.size == 0:
        return {"error": "Empty data array"}

    # Baseline window
    bl_i0 = int(np.searchsorted(time, baseline_start, side="left"))
    bl_i1 = int(np.searchsorted(time, baseline_end, side="right"))
    baseline_seg = data[bl_i0:bl_i1]
    if baseline_seg.size < 2:
        return {"error": "Baseline window too narrow (need >= 2 samples)"}
    baseline_mean = float(np.mean(baseline_seg))

    # Integration window
    win_i0 = int(np.searchsorted(time, window_start, side="left"))
    win_i1 = int(np.searchsorted(time, window_end, side="right"))
    win_time = time[win_i0:win_i1]
    win_data = data[win_i0:win_i1] - baseline_mean

    if win_data.size < 2:
        return {"error": "Integration window too narrow (need >= 2 samples)"}

    # np.trapz integrates pA * s = pC
    charge_pC = float(np.trapz(win_data, win_time))

    return {
        "module_used": "synaptic_charge",
        "metrics": {
            "Total_Charge_pC": round(charge_pC, 4),
        },
        # Scalar top-level key for the results table
        "Total_Charge_pC": round(charge_pC, 4),
        "Baseline_pA": round(baseline_mean, 4),
        # Private keys for plot overlays
        "_baseline_level": baseline_mean,
        "_int_x": win_time.tolist(),
        "_int_y": (data[win_i0:win_i1]).tolist(),
        "_base": baseline_mean,
    }


# ── Part 2: Registry wrapper ──────────────────────────────────────
@AnalysisRegistry.register(
    name="synaptic_charge",
    label="Synaptic Charge Transfer",
    ui_params=[
        {
            "name": "baseline_start",
            "label": "Baseline Start (s):",
            "type": "float",
            "default": 0.0,
            "min": 0.0,
            "max": 1e9,
            "decimals": 4,
        },
        {
            "name": "baseline_end",
            "label": "Baseline End (s):",
            "type": "float",
            "default": 0.05,
            "min": 0.0,
            "max": 1e9,
            "decimals": 4,
        },
        {
            "name": "window_start",
            "label": "Integration Start (s):",
            "type": "float",
            "default": 0.05,
            "min": 0.0,
            "max": 1e9,
            "decimals": 4,
        },
        {
            "name": "window_end",
            "label": "Integration End (s):",
            "type": "float",
            "default": 0.3,
            "min": 0.0,
            "max": 1e9,
            "decimals": 4,
        },
    ],
    plots=[
        # Draggable region over the baseline window
        {"type": "interactive_region", "data": ["baseline_start", "baseline_end"], "color": "b"},
        # Draggable region over the integration window
        {"type": "interactive_region", "data": ["window_start", "window_end"], "color": "g"},
        # Horizontal line at the baseline level
        {"type": "hlines", "data": ["_baseline_level"], "color": "b", "styles": ["dash"]},
        # Shaded fill between the raw current and the baseline level
        {
            "type": "fill_between",
            "x": "_int_x",
            "y1": "_int_y",
            "y2": "_base",
            "brush": (0, 100, 255, 100),
        },
    ],
)
def run_synaptic_charge_wrapper(
    data: np.ndarray,
    time: np.ndarray,
    sampling_rate: float,
    **kwargs,
) -> Dict[str, Any]:
    """Registry wrapper - extracts kwargs and calls pure logic."""
    return calculate_synaptic_charge(
        data=data,
        time=time,
        sampling_rate=sampling_rate,
        window_start=kwargs.get("window_start", 0.05),
        window_end=kwargs.get("window_end", 0.3),
        baseline_start=kwargs.get("baseline_start", 0.0),
        baseline_end=kwargs.get("baseline_end", 0.05),
    )
```

The key design points illustrated here:
- A dedicated **baseline window** is used to subtract the holding current
  before integration, keeping the result physically meaningful.
- `np.trapz` is used for trapezoidal integration (not a simple sum), which is
  exact for linear interpolations between sample points.
- The return dict follows the **nested output schema** `{"module_used": ...,
  "metrics": {...}}` alongside flat scalar keys for the results table.
- Two `interactive_region` overlays let the user drag both windows directly on
  the trace without typing numbers.
- A `fill_between` overlay shades the integrated area, making the charge
  visually obvious on the trace.
- `_baseline_level`, `_int_x`, `_int_y`, and `_base` (private keys) feed the
  plot overlays without appearing in the results table.

---

## 10. Applying Global Themes to Plugins

Synaptipy ships a global plot-customisation system (`shared/plot_customization.py`)
that lets users change trace colours, line widths, scatter sizes, and region
fills from **Edit > Preferences > Plot Customisation**.  When a user saves new
preferences, a `preferences_updated` signal fires and all built-in canvases
instantly re-style their existing plot items - no file reload required.

### How the theme reaches your plugin

The registry wrapper receives all GUI state through `**kwargs`.  Synaptipy
injects a `theme_config` dict whenever the current plot-customisation settings
are relevant to the analysis call.  Your wrapper can read it like this:

```python
@AnalysisRegistry.register(name="my_metric", ...)
def run_my_metric_wrapper(data, time, sampling_rate, **kwargs):
    theme_config = kwargs.get("theme_config", {})

    # Retrieve individual style properties with sensible defaults
    trace_color  = theme_config.get("single_trial_color", (200, 200, 200))
    avg_color    = theme_config.get("average_color",      (255, 255,   0))
    scatter_color = theme_config.get("scatter_color",     (255,   0,   0))
    line_width   = theme_config.get("line_width",         1.5)

    return my_metric_logic(data, time, sampling_rate,
                           trace_color=trace_color,
                           avg_color=avg_color)
```

Available keys in `theme_config`:

| Key | Type | Description |
|---|---|---|
| `single_trial_color` | `(R, G, B)` tuple | Colour of individual trial traces |
| `average_color` | `(R, G, B)` tuple | Colour of the average overlay |
| `scatter_color` | `(R, G, B)` tuple | Colour of scatter / event markers |
| `region_color` | `(R, G, B, A)` tuple | Fill colour of `LinearRegionItem`s |
| `threshold_color` | `(R, G, B)` tuple | Colour of threshold `InfiniteLine`s |
| `line_width` | `float` | Stroke width for all trace pens |
| `scatter_size` | `int` | Symbol size for scatter plots |

Always guard with `.get("key", default)` so your plugin works even when
`theme_config` is absent (e.g. in batch-only usage or unit tests).

### Returning styled plot data

If your analysis produces overlay arrays (stored under private `_`-prefixed
keys), you can store the chosen colour alongside them so the canvas can apply
it verbatim:

```python
return {
    "My_Value": 42.0,
    "_plot_x": time_slice,
    "_plot_y": data_slice,
    "_plot_color": theme_config.get("average_color", (255, 255, 0)),
}
```

The canvas reads `_plot_color` when drawing the overlay curve and passes it
directly to `pg.mkPen()`.

### Listening to live theme changes in a popup window

If your plugin opens a secondary `pg.PlotWidget` popup (via the `popup_xy`
plot type), connect to the global signal so it re-styles on the fly:

```python
from Synaptipy.shared.plot_customization import get_plot_customization_signals

signals = get_plot_customization_signals()
signals.preferences_updated.connect(my_popup_widget.update_pens)
```

---

## 11. SpikeInterface Integration Plugin

Synaptipy ships a ready-to-use plugin that integrates
[SpikeInterface](https://spikeinterface.readthedocs.io/) spike detection
directly into the standard Analyser workflow.  Because SpikeInterface is an
optional dependency it is **not** listed in `requirements.txt`; install it once
with `pip install spikeinterface`.

### What the plugin does

1. You select the extracellular field channel in the Analyser tab (exactly like
   any other built-in analysis).
2. The plugin wraps the selected 1-D numpy array in a
   `spikeinterface.core.NumpyRecording`, bandpass-filters it (default
   300-6000 Hz), and runs `detect_peaks` with the `by_channel` method - no
   external sorter binary is required.
3. Detected spike times are shown as:
   - **Red dashed vertical lines** on the trace (one line per spike).
   - **Red scatter markers** at the filtered amplitude of each peak.
4. Summary metrics appear in the Results panel: `Spike_Count`,
   `Noise_Estimate`, `Threshold`, and `Mean_Firing_Rate_Hz`.

### Before running

![SpikeInterface plugin - parameters ready](tutorial/screenshots/plugin_spike_interface_empty.png)

The tab renders automatically when the plugin file is present in
`examples/plugins/` or `~/.synaptipy/plugins/`.  The **Run Analysis** button
(`run_button=True` in the decorator) means the analysis only executes when
clicked - it does not re-run on every parameter change, which is appropriate
for the relatively expensive bandpass + peak-detection pipeline.

### After running - 3 detected spikes

![SpikeInterface plugin - 3 spikes detected](tutorial/screenshots/plugin_spike_interface_detected.png)

Red dashed lines mark each detected spike time; red circles mark the filtered
amplitude at each peak.  The Results panel shows all four metrics.

### Installation

```bash
# Activate the Synaptipy environment and install SpikeInterface
conda activate synaptipy
pip install spikeinterface

# The plugin is already present at:
#   examples/plugins/spike_interface_integration.py
# Copy it to your personal directory for auto-load on every launch:
cp examples/plugins/spike_interface_integration.py ~/.synaptipy/plugins/
```

### Key parameters

| Parameter | Default | Description |
|---|---|---|
| `freq_min` | 300 Hz | Lower bandpass cutoff (removes LFP / baseline drift) |
| `freq_max` | 6000 Hz | Upper bandpass cutoff (removes high-frequency noise) |
| `threshold_mad` | 5 | Spike threshold as a multiple of the MAD noise estimate |
| `peak_sign` | `neg` | Polarity: `neg` for field potentials, `pos` for some units |
| `exclude_sweep_ms` | 5 ms | Refractory period - prevents double-counting secondary deflections |

### Testing the plugin without running the full CI suite

The plugin tests live in `examples/tests/` and are intentionally excluded from
the core CI `testpaths` (which only scans `tests/`).  Run them manually:

```bash
conda run -n synaptipy python -m pytest examples/tests/ -v
```

---

## 12. Deep Learning & Third-Party Integrations (e.g., miniML)

Synaptipy's plugin architecture is intentionally decoupled from its core
dependencies. You can integrate heavy machine-learning or third-party
libraries -- such as [miniML](https://github.com/delvendahl/miniML), a
deep-learning framework for synaptic event detection -- without modifying
any Synaptipy source files and without adding those dependencies to
`requirements.txt`, `pyproject.toml`, or `environment.yml`.

> **CI contract:** `miniML`, `tensorflow`, `keras`, `ruptures`, and any other
> third-party ML library must **never** be added to Synaptipy's core dependency
> files. The CI pipelines are headless, fast, and must remain completely
> decoupled from these optional dependencies.

### 4-step workflow

1. **Clone miniML once** (outside the Synaptipy directory):
   ```bash
   git clone https://github.com/delvendahl/miniML.git ~/miniML
   ```
   miniML is **not** a pip package -- it is a collection of Python source
   files.  The plugin adds `miniML/core/` to `sys.path` at runtime.

2. **Install miniML's Python dependencies** into the Synaptipy environment:
   ```bash
   conda activate synaptipy
   pip install "tensorflow>=2.12,<2.16" scikit-learn ruptures==1.1.10
   ```
   > **Do NOT run `pip install -r ~/miniML/requirements.txt`.**
   > That file pins `numpy==1.23.5` and `pandas==1.5.3`, which are
   > incompatible with Synaptipy's `numpy>=2.0` requirement and will
   > break `np.trapezoid` calls throughout the application.

3. **Copy the plugin** to your personal plugin directory:
   ```bash
   # macOS / Linux
   cp examples/plugins/miniml_integration.py ~/.synaptipy/plugins/

   # Windows (PowerShell)
   Copy-Item examples\plugins\miniml_integration.py ~\.synaptipy\plugins\
   ```
   Enable custom plugins under **Edit > Preferences**
   (or **Synaptipy > Preferences** on macOS) and restart.

4. **Use the Browse buttons in the GUI**:
   - Open a recording, switch to the **Analyser** tab, click **miniML Events**.
   - Click **Browse...** next to **miniML core/ Path** and navigate to the
     `core/` sub-directory inside the cloned repo (e.g. `~/miniML/core/`).
   - Click **Browse...** next to **Model Path (.h5)** and navigate to a
     `.h5` model file (e.g. `~/miniML/models/GC_lstm_model.h5`).
   - Adjust **Prediction Threshold** and **Direction**, then click **Run Analysis**.

### miniML Integration Example

Because Synaptipy plugins run in your local environment, you can easily
integrate heavy ML frameworks like `miniML` without bloating Synaptipy's
core dependencies. We provide a template at
`examples/plugins/miniml_integration.py`.

#### Installation warning

Do NOT use `pip install miniML` (it targets an unrelated database package
on PyPI) and do NOT use `git clone` inside the Synaptipy directory (to
avoid git tracking conflicts). Install via the GitHub URL above.

### Template: `examples/plugins/miniml_integration.py`

A fully annotated, copy-pasteable template is provided at
`examples/plugins/miniml_integration.py`. It demonstrates:

- **Lazy `sys.path` import** -- the plugin adds the `miniML/core/` directory
  to `sys.path` at analysis time (via `_import_miniml(miniml_core_path)`),
  so the library is never imported at Synaptipy startup.  This means the tab
  appears even when miniML is not installed; users only see an error if they
  click **Run Analysis** without filling in the paths.
- **`dirpath` Browse button** for `miniml_core_path` -- users click Browse
  instead of typing a path.
- **`filepath` Browse button** for `model_path` -- opens a file picker
  pre-filtered to `.h5` / `.keras` model files.
- How to expose `threshold`, `direction`, and `batch_size` as GUI widgets.
- How to return private `_event_times` / `_event_peaks` keys for plot
  overlays while keeping `Event_Count`, `Frequency_Hz`, and `Model_Used`
  visible in the results table.
- **`run_button=True`** in the decorator -- for analyses that should only run
  on explicit user action (rather than on every parameter change), set this
  flag to add a dedicated **Run Analysis** button to the tab.

### miniML API notes (common pitfalls)

The miniML library splits its configuration across **two** call sites.
Getting this wrong causes a `TypeError` or silent bad output.

#### `EventDetection.__init__()` parameters

Pass only these arguments to the constructor:

```python
detector = EventDetection(
    data=trace,
    event_direction=direction,   # "negative" or "positive"
    model_path=active_model,
    model_threshold=threshold,
    batch_size=batch_size,
    window_size=window_size,
)
```

`rel_prom_cutoff`, `convolve_win`, and `gradient_convolve_win` are **not**
accepted by `__init__()` -- passing them there raises a `TypeError`.

#### `detect_events()` parameters

All post-processing controls belong in the `detect_events()` call:

```python
detector.detect_events(
    eval=True,
    rel_prom_cutoff=rel_prom_cutoff,   # default 0.25
    convolve_win=convolve_win,          # default 20
    gradient_convolve_win=gradient_convolve_win,  # default 40
)
```

> **Never pass `convolve_win=0` or `gradient_convolve_win=0`.**
> miniML uses these as slice offsets: `smth_gradient[-0:]` in Python evaluates
> to the entire array because `-0 == 0`, silently zeroing the gradient signal.
> With all-zero gradient, `np.std([])` returns `NaN` in NumPy >= 2.0,
> causing a `ValueError: cannot convert float NaN to integer` deep inside
> miniML's peak-finding code.  Use the documented defaults (20 and 40) or
> any positive non-zero value.

#### Marker placement: onset vs. amplitude peak

`detector.event_locations` contains **onset** indices (the steepest-slope
sample), not amplitude peaks.  If you plot markers directly at those indices
the dots appear shifted left relative to the visible peak.

The plugin searches forward from each onset within `window_size // 2` samples
using `np.argmin` (negative events) or `np.argmax` (positive events) to find
the true peak:

```python
half_win = max(1, window_size // 2)
find_extremum = np.argmin if direction == "negative" else np.argmax
for k, onset in enumerate(valid_locs):
    end_idx = min(int(onset) + half_win, len(data))
    seg = data[int(onset):end_idx]
    peak_indices[k] = onset + find_extremum(seg) if len(seg) > 0 else onset
```

### Adapting the template to other ML tools

The same pattern works for any inference library:

| Part | What to change |
|---|---|
| `try/except Exception` block | Replace `miniML` imports with your library |
| `model_path` `ui_params` entry | Use whatever path or identifier your model needs |
| `run_*_detection()` function | Replace miniML API calls with your library's API |
| `ui_params` | Expose whichever hyperparameters the user should control |
| `plots` | Map returned private keys to the overlay type that fits |

---

## 13. Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| Tab does not appear | Plugin file has a syntax error | Check the Synaptipy log (`~/.synaptipy/synaptipy.log`) for `SyntaxError` messages. |
| Tab does not appear | File not in the plugins folder | Verify the path: macOS/Linux: `ls ~/.synaptipy/plugins/` - Windows: `dir %USERPROFILE%\.synaptipy\plugins` |
| Tab does not appear | Missing `@AnalysisRegistry.register` decorator | The file must contain a decorated function. |
| `ImportError` in log | Plugin imports a package not installed in your environment | Install the dependency: `pip install <package>` |
| `name "X" is already registered` warning | Two plugins use the same `name=` string | Change one plugin's `name=` to something unique. |
| Parameters don't show up | `ui_params` has a typo in `"type"` | Must be one of: `"float"`, `"int"`, `"bool"`, `"choice"`, `"string"`, `"filepath"`, `"dirpath"` |
| Browse button not shown | Using `"type": "string"` instead of `"filepath"` or `"dirpath"` | Change type to `"filepath"` (file picker) or `"dirpath"` (folder picker). |
| miniML tab appears but shows error on Run | `miniml_core_path` not set or wrong folder | Browse to the `core/` sub-directory (the folder containing `miniML.py`). |
| Plot overlay missing | Result dict key doesn't match `plots` data key | The key in `"data"` must exactly match a key in the returned dict. |
| Results table shows `_private` keys | Keys must start with underscore | Prefix with `_`: `"_fit_data"` |
| Built-in contrib: 0 tabs on Windows | Forgot to add `from . import X` in `__init__.py` | See §7, step 2. |
