Skip to content

Commit 7c68475

Browse files
authored
Sync with main (#19)
1 parent b7377e2 commit 7c68475

15 files changed

Lines changed: 1379 additions & 150 deletions

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,11 @@ cython_debug/
169169

170170
# PyPI configuration file
171171
.pypirc
172+
173+
# VSCODE
174+
.vscode/
175+
176+
# CLAUDE
177+
.claude/
178+
CLAUDE.md
179+
*MEMORY.md

Dockerfile

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,23 @@ RUN wget "https://www.wavpack.com/wavpack-${WAVPACK_VERSION}.tar.bz2" && \
2222
# Install
2323
RUN pip install wavpack-numcodecs
2424

25+
# Install spikeinterface from source
26+
RUN git clone https://github.com/SpikeInterface/spikeinterface.git && \
27+
cd spikeinterface && \
28+
git checkout f732780fd88f5802033b57c9bb9b06229ec7de30 && \
29+
pip install . && cd ..
30+
31+
# Force scikit-learn to 1.6.1 to avoid issues with newer versions
32+
RUN pip install scikit-learn==1.6.1
33+
2534
# Install spikeinterface-gui from source
26-
RUN git clone https://github.com/SpikeInterface/spikeinterface-gui.git && \
35+
RUN git clone https://github.com/alejoe91/spikeinterface-gui.git && \
2736
cd spikeinterface-gui && \
28-
git checkout e7c37434169df1ad107acdc2b7a6b4d3fd18821a && \
37+
git checkout 47999372e405f5d7a435072ca3015a9fd1b9812c && \
2938
pip install . && cd ..
3039

3140

41+
ENV PYTHONUNBUFFERED=1
42+
3243
EXPOSE 8000
33-
ENTRYPOINT ["sh", "-c", "panel serve src/aind_ephys_portal/ephys_portal_app.py src/aind_ephys_portal/ephys_gui_app.py --static-dirs images=src/aind_ephys_portal/images --address 0.0.0.0 --port 8000 --allow-websocket-origin ${ALLOW_WEBSOCKET_ORIGIN} --keep-alive 10000 --index ephys_portal_app.py --warm"]
44+
ENTRYPOINT ["sh", "-c", "panel serve src/aind_ephys_portal/ephys_gui_app.py src/aind_ephys_portal/ephys_portal_app.py src/aind_ephys_portal/ephys_launcher_app.py src/aind_ephys_portal/ephys_monitor_app.py --setup src/aind_ephys_portal/setup.py --static-dirs images=src/aind_ephys_portal/images --address 0.0.0.0 --port 8000 --allow-websocket-origin ${ALLOW_WEBSOCKET_ORIGIN} --index ephys_portal_app.py --check-unused-sessions 2000 --unused-session-lifetime 5000 --num-threads 8"]

README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,24 @@ pip install -e .
2424

2525
## Usage
2626

27-
To run the Ephys Portal:
27+
To run the Ephys Portal you can either use tha `launch.sh` script:
2828

2929
```bash
30-
# Run using Panel CLI (recommended for server deployment)
31-
panel serve src/aind_ephys_portal/ephys_portal_app.py src/aind_ephys_portal/ephys_gui_app.py --static-dirs images=src/aind_ephys_portal/images --autoreload
30+
bash launch.sh
31+
```
32+
33+
or serve panel apps directly:
34+
35+
```bash
36+
panel serve \
37+
src/aind_ephys_portal/ephys_gui_app.py \
38+
src/aind_ephys_portal/ephys_launcher_app.py \
39+
src/aind_ephys_portal/ephys_monitor_app.py \
40+
src/aind_ephys_portal/ephys_log_app.py \
41+
--setup src/aind_ephys_portal/setup.py \
42+
--static-dirs images=src/aind_ephys_portal/images \
43+
--index ephys_portal_app.py \
44+
--num-procs 4
3245
```
3346

3447
This will start a Panel server and make the application available in your web browser.
@@ -45,7 +58,7 @@ pip install -e ".[dev]"
4558
1. Build the Docker image locally and run a Docker container:
4659
```sh
4760
docker build -t aind-ephys-portal .
48-
docker run -e ALLOW_WEBSOCKET_ORIGIN=0.0.0.0:8000 -p 8000:8000 aind-ephys-portal
61+
docker run --rm -e ALLOW_WEBSOCKET_ORIGIN=0.0.0.0:8000 -v ~/.aws:/root/.aws:ro -p 8000:8000 aind-ephys-portal
4962
```
5063
2. Navigate to '0.0.0.0:8000` to view the app.
5164

launch.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/sh
2+
3+
PORT=${1:-5006}
4+
5+
# Clean up old logs
6+
rm -rf /tmp/aind_ephys_logs
7+
8+
panel serve \
9+
src/aind_ephys_portal/ephys_gui_app.py \
10+
src/aind_ephys_portal/ephys_portal_app.py \
11+
src/aind_ephys_portal/ephys_launcher_app.py \
12+
src/aind_ephys_portal/ephys_monitor_app.py \
13+
--setup src/aind_ephys_portal/setup.py \
14+
--static-dirs images=src/aind_ephys_portal/images \
15+
--check-unused-sessions 2000 \
16+
--unused-session-lifetime 5000 \
17+
--port $PORT \
18+
--num-threads 8

pyproject.toml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ authors = [
1313
{name = "Allen Institute for Neural Dynamics"}
1414
]
1515
dependencies = [
16-
"panel>=1.0.0,<1.6.2",
16+
"panel>=1.0.0",
1717
"param>=1.12.0",
18-
"aind-data-access-api>=0.1.0",
19-
"spikeinterface[full]>=0.102.0",
20-
"pandas>=1.2.0",
21-
"numpy>=1.20.0",
18+
"psutil",
2219
"boto3",
2320
"s3fs>=2025.2.0",
24-
"numcodecs<0.16.0",
21+
"aind-data-access-api>=0.1.0",
22+
"spikeinterface[full]>=0.103.0",
2523
"spikeinterface-gui"
2624
]
2725

src/aind_ephys_portal/docdb/database.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,27 @@ def get_raw_asset_by_name(asset_name: str):
102102

103103

104104
@pn.cache(ttl=TIMEOUT_1H)
105-
def get_all_ecephys_derived() -> List[Dict[str, Any]]:
105+
def get_all_ecephys_derived(additional_includes_in_name: str | None = None) -> List[Dict[str, Any]]:
106106
"""Get a limited set of all records from the database.
107107
108108
Returns
109109
-------
110110
list[dict]
111111
List of records, limited to 50 entries.
112+
additional_includes_in_name : str, optional
113+
Comma-separated list of additional fields to include in the results, by default None
112114
"""
113115
filter_query = {"data_description.modality.abbreviation": "ecephys", "data_description.data_level": "derived"}
114-
response = client.retrieve_docdb_records(
116+
responses = client.retrieve_docdb_records(
115117
filter_query=filter_query,
116118
)
117-
return response
119+
responses_filt = []
120+
if additional_includes_in_name:
121+
additional_fields = [field.strip() for field in additional_includes_in_name.split(",")]
122+
for record in responses:
123+
for field in additional_fields:
124+
if field in record["name"]:
125+
responses_filt.append(record)
126+
else:
127+
responses_filt = responses
128+
return responses_filt

src/aind_ephys_portal/ephys_gui_app.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,77 @@
33
import urllib
44
import param
55

6-
from aind_ephys_portal.panel.ephys_gui import EphysGuiView
7-
86
import panel as pn
97

108
pn.extension("tabulator", "gridstack")
119

12-
from spikeinterface_gui.utils_panel import KeyboardShortcuts
10+
# Import for side effects: registers SpikeInterface GUI custom Panel/Bokeh models/resources
11+
import spikeinterface_gui.utils_panel # noqa: F401
1312

13+
from aind_ephys_portal.panel.ephys_gui import EphysGuiView
14+
15+
def _get_arg(name: str, default: str = "") -> str:
16+
"""Read a query-parameter from the raw HTTP request."""
17+
val = pn.state.session_args.get(name, [default.encode()])
18+
if isinstance(val, list):
19+
val = val[0]
20+
if isinstance(val, bytes):
21+
val = val.decode()
22+
return val
23+
1424

1525
# State sync
1626
class Settings(param.Parameterized):
1727
"""Top-level settings for QC app"""
1828

1929
analyzer_path = param.String(default="")
2030
recording_path = param.String(default="")
31+
identifier = param.String(default="")
32+
fast_mode = param.Boolean(
33+
default=False,
34+
doc="Whether to enable fast mode (skips waveforms and principal components)"
35+
)
36+
preload_curation = param.Boolean(
37+
default=False,
38+
doc="Whether to preload existing curation from disk (if available)"
39+
)
2140

2241

2342
settings = Settings()
24-
pn.state.location.sync(settings, {"analyzer_path": "analyzer_path", "recording_path": "recording_path"})
43+
pn.state.location.sync(settings, {"analyzer_path": "analyzer_path", "recording_path": "recording_path", "identifier": "identifier", "fast_mode": "fast_mode", "preload_curation": "preload_curation"})
44+
45+
46+
47+
analyzer_path = urllib.parse.unquote(_get_arg("analyzer_path"))
48+
recording_path = urllib.parse.unquote(_get_arg("recording_path"))
49+
identifier = urllib.parse.unquote(_get_arg("identifier"))
50+
fast_mode = _get_arg("fast_mode", "false").lower() in ("true", "1", "yes")
51+
preload_curation = _get_arg("preload_curation", "false").lower() in ("true", "1", "yes")
52+
53+
print(f"Parsed arguments:")
54+
print(f"\tanalyzer_path={analyzer_path}\n\trecording_path={recording_path}\n\tidentifier={identifier}\n\tfast_mode={fast_mode}\n\tpreload_curation={preload_curation}")
55+
56+
ephys_gui = EphysGuiView(analyzer_path=analyzer_path, recording_path=recording_path, identifier=identifier, fast_mode=fast_mode, preload_curation=preload_curation)
2557

26-
# Manually decode stream_name after syncing
27-
settings.analyzer_path = urllib.parse.unquote(settings.analyzer_path)
28-
settings.recording_path = urllib.parse.unquote(settings.recording_path)
58+
ephys_gui.panel().servable(title="AIND Ephys GUI")
2959

60+
# Register session cleanup on the document. The document is definitively bound
61+
# to this session at module-execution time, so curdoc is correct here.
62+
# Factory function creates a proper closure (cell variable) for view_ref,
63+
# which is immune to Panel's exec() scoping (exec uses separate globals/locals
64+
# dicts, so plain module-level names are inaccessible inside callbacks).
65+
def _make_cleanup_callback(view):
66+
view_ref = [view]
67+
def _on_session_destroyed(session_context):
68+
v = view_ref[0]
69+
view_ref[0] = None # always break the reference, even if cleanup raises
70+
if v is not None:
71+
v.cleanup()
72+
return _on_session_destroyed
3073

31-
ephys_gui = EphysGuiView(analyzer_path=settings.analyzer_path, recording_path=settings.recording_path)
32-
ephys_gui_panel = ephys_gui.panel()
74+
pn.state.curdoc.on_session_destroyed(_make_cleanup_callback(ephys_gui))
75+
print(f"[GUI] Cleanup registered on doc {id(pn.state.curdoc)}")
3376

34-
ephys_gui_panel.servable(title="AIND Ephys GUI")
77+
# Don't keep a module-level reference to the heavy GUI object.
78+
# The servable layout is now owned by Panel/Bokeh's document.
79+
del ephys_gui
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Standalone launcher page to generate GUI links."""
2+
3+
import urllib.parse
4+
5+
import panel as pn
6+
7+
from aind_ephys_portal.panel.logging import setup_logging
8+
from aind_ephys_portal.panel.utils import EPHYSGUI_LINK_PREFIX
9+
10+
pn.extension()
11+
12+
13+
class EphysLauncher:
14+
def __init__(self):
15+
setup_logging()
16+
17+
self.analyzer_input = pn.widgets.TextInput(
18+
name="Analyzer path",
19+
value="",
20+
height=50,
21+
sizing_mode="stretch_width",
22+
)
23+
self.recording_input = pn.widgets.TextInput(
24+
name="Recording path (optional)",
25+
value="",
26+
height=50,
27+
sizing_mode="stretch_width",
28+
)
29+
30+
self.generate_button = pn.widgets.Button(
31+
name="Generate Link",
32+
button_type="primary",
33+
height=45,
34+
sizing_mode="stretch_width",
35+
)
36+
37+
self.fast_mode_checkbox = pn.widgets.Checkbox(
38+
name="Enable Fast Mode (skip waveforms and PCs)",
39+
value=False,
40+
height=30,
41+
sizing_mode="stretch_width",
42+
)
43+
44+
self.preload_curation = pn.widgets.Checkbox(
45+
name="Preload Curation",
46+
value=True,
47+
height=30,
48+
sizing_mode="stretch_width",
49+
)
50+
51+
self.link_pane = pn.pane.HTML("No link generated yet.", sizing_mode="stretch_width")
52+
self.url_output = pn.widgets.TextInput(
53+
name="GUI URL",
54+
value="",
55+
disabled=True,
56+
sizing_mode="stretch_width",
57+
)
58+
59+
self.generate_button.on_click(self._update_link)
60+
61+
self.layout = pn.Column(
62+
pn.pane.Markdown("## AIND Ephys Launcher"),
63+
self.analyzer_input,
64+
self.recording_input,
65+
pn.Row(self.generate_button, self.fast_mode_checkbox, self.preload_curation, sizing_mode="stretch_width"),
66+
self.link_pane,
67+
self.url_output,
68+
sizing_mode="stretch_width",
69+
)
70+
71+
def _build_gui_url(self):
72+
analyzer_path = self.analyzer_input.value.strip()
73+
recording_path = self.recording_input.value.strip()
74+
if not analyzer_path:
75+
return None
76+
77+
analyzer_path_q = urllib.parse.quote(analyzer_path, safe="")
78+
recording_path_q = urllib.parse.quote(recording_path, safe="") if recording_path else ""
79+
path = EPHYSGUI_LINK_PREFIX.format(analyzer_path_q, recording_path_q)
80+
if self.fast_mode_checkbox.value:
81+
path += "&fast_mode=true"
82+
if self.preload_curation.value:
83+
path += "&preload_curation=true"
84+
85+
location = pn.state.location
86+
if location is not None:
87+
href = getattr(location, "href", None)
88+
if href:
89+
parsed = urllib.parse.urlparse(href)
90+
if parsed.scheme and parsed.netloc:
91+
return f"{parsed.scheme}://{parsed.netloc}{path}"
92+
93+
protocol = getattr(location, "protocol", "")
94+
hostname = getattr(location, "hostname", "")
95+
port = getattr(location, "port", "")
96+
if protocol and hostname:
97+
netloc = f"{hostname}:{port}" if port else hostname
98+
return f"{protocol}//{netloc}{path}"
99+
100+
return path
101+
102+
def _update_link(self, event=None):
103+
url = self._build_gui_url()
104+
if not url:
105+
self.link_pane.object = "<span style='color: orange;'>⚠️ Analyzer path is required.</span>"
106+
self.url_output.value = ""
107+
return
108+
print(f"Generated URL: {url}")
109+
110+
self.link_pane.object = f'<a href="{url}" target="_blank">Ephys Curation GUI</a>'
111+
self.url_output.value = url
112+
113+
114+
app = EphysLauncher()
115+
app.layout.servable(title="AIND Ephys Launcher")

0 commit comments

Comments
 (0)