concord-server

FastAPI backend + browser-based interactive dashboard

Role in the system The server is the user-facing layer. It holds session state (the currently loaded Recording), exposes an HTTP API that the browser calls, and serves the static frontend files. All the real computation happens in the packages below — the server just orchestrates them.
New to REST APIs? If terms like GET, POST, and JSON are unfamiliar, read REST API & HTTP first. For how FastAPI works, see FastAPI Framework.

Package Structure

concord-server/
  src/concord_server/
    server.py               FastAPI app, mounts routers, serves static files
    state.py                AppState dataclass + helper functions
    routes/
      recording.py          /api/load, /api/montage, /api/browse, /api/events
      signal.py             /api/timeseries, /api/psd, /api/spectrogram
      metrics.py            /api/metric/*, /api/electrode_positions, /api/brain_timeseries
      notch.py              /api/notch
      bids.py               /api/bids/*, /api/browse_dirs
    static/
      index.html            Dashboard HTML shell
      app.js, panels.js, timeseries.js, spectral.js, metrics.js, brain.js, browser.js
      style.css

state.py — Session State

The server uses a single global AppState object to track what the user has loaded. This is a simple in-memory store — no database, no file system.

dataclassAppState
FieldTypeDescription
recordingRecording | NoneCurrently active recording (montaged + filtered). What all API routes serve from.
raw_recordingRecording | NoneOriginal monopolar data from EDF. Never touched after load — used to rebuild when user changes montage or notch.
pathstrPath of the currently loaded EDF file.
notch_modestr"none" | "notch50" | "notch60"
montage_modestr"monopolar" | "bipolar" | "car"
dataset_rootstrRoot directory for BIDS dataset browsing. Set via CLI --data-root, CONCORD_DATA_ROOT env var, or the folder picker.
dataset_pathstrPath to the currently selected BIDS dataset directory.

State functions

FunctionDescription
get_state() → AppStateReturns the singleton state object. Routes call this to read/modify state.
set_recording(recording, path)Called after successful load. Sets both raw_recording and recording to the freshly loaded data, resets montage/notch to defaults.
clear()Resets all state fields to None/defaults.
require_recording() → RecordingReturns recording if loaded, raises HTTP 400 if not. Used by all routes that need data.

The rebuild pattern

When the user changes montage or notch, the server doesn't modify the existing recording. Instead, it rebuilds from scratch using _rebuild_recording(state):

def _rebuild_recording(s: AppState) -> None:
    # Step 1: Apply montage to raw data
    if s.montage_mode == "bipolar":
        rec = to_bipolar(s.raw_recording)
    elif s.montage_mode == "car":
        rec = to_car(s.raw_recording)
    else:
        rec = s.raw_recording

    # Step 2: Apply notch filter
    if s.notch_mode in ("notch50", "notch60"):
        freq = 50.0 if s.notch_mode == "notch50" else 60.0
        for harmonic in [freq, 2.0 * freq]:
            filtered = notch(filtered, rec.fs, freq=harmonic)

    s.recording = rec   # store new result

API Routes

Recording Routes — routes/recording.py

POST/api/load

Body: {"path": "/absolute/path/to/file.edf"}

Loads a BIDS iEEG EDF file. Calls read_bids_ieeg(), stores in state.

Returns:

{
  "path": "...",
  "channels": ["SEEG1", ...],
  "n_channels": 72,
  "fs": 1024.0,
  "duration": 300.0,
  "montage": "monopolar",
  "channel_metadata": {
    "SEEG1": {"status": "good", "status_description": "soz", "x": 12.3, "y": -4.1, "z": 8.5}
  },
  "events": [{"onset": 45.2, "duration": 60.0, "label": "seizure"}]
}

Errors: 404 if file not found, 422 if file cannot be parsed.

POST/api/montage

Body: {"montage": "bipolar"} (or "monopolar" | "car")

Changes the active montage. Triggers rebuild from raw_recording.

Returns: {"montage": "bipolar", "channels": [...], "channel_metadata": {...}}

Note: bipolar channel names change (e.g. "SEEG1-SEEG2"), so the frontend rebuilds the sidebar.

GET/api/browse?path=/home/user/data

Returns a directory listing for the file browser dialog.

Returns: {"path": "...", "parent": "...", "entries": [{"name": "file.edf", "is_dir": false, "size": 123456}, ...]}

Hidden files (starting with ".") are excluded.

GET/api/events

Returns the event annotations for the current recording.

Returns: {"events": [{"onset": 45.2, "duration": 60.0, "label": "seizure"}, ...]}

Signal Routes — routes/signal.py

GET/api/timeseries?t_start=0&t_end=10&channels=SEEG1,SEEG2

Returns downsampled time series data for rendering. Calls get_timeseries() from concord-viz.

Query paramDescription
t_startStart time in seconds (optional)
t_endEnd time in seconds (optional)
channelsComma-separated channel names (optional, defaults to all)

Returns: {"channels": [...], "times": [...], "values": [[...], ...], "events": [...]}

GET/api/psd?channels=SEEG1,SEEG2

Returns Welch PSD for selected channels. Calls get_psd().

Returns: {"channels": [...], "freqs": [...], "power": [[...], ...]}

GET/api/spectrogram?channel=SEEG1

Returns STFT spectrogram for a single channel. Calls get_spectrogram().

Returns: {"times": [...], "freqs": [...], "power": [[...], ...]} (power in dB)

Metric Routes — routes/metrics.py

GET/api/metric/line_length?window_s=1.0

Returns windowed line length for all channels.

GET/api/metric/hjorth?window_s=1.0

Returns windowed Hjorth parameters (activity, mobility, complexity) for all channels.

GET/api/metric/band_power?window_s=4.0

Returns band power per channel across 6 frequency bands.

GET/api/electrode_positions

Returns MNI coordinates for all channels that have them.

Returns: {"channels": [{"name": "SEEG1", "x": 12.3, "y": -4.1, "z": 8.5, "status": "good", "status_description": "soz"}, ...]}

GET/api/brain_timeseries?metric=line_length&component=0&window_s=1.0

Returns a scalar value per channel per time window for animating the 3D brain visualization.

Returns: {"channels": [...], "times": [...], "values": [[ch1_win1, ch1_win2, ...], ...]}

component selects which Hjorth parameter (0=activity, 1=mobility, 2=complexity) or band power index to extract.

Notch Route — routes/notch.py

POST/api/notch

Body: {"mode": "notch60"} (or "none" | "notch50")

Changes the active notch filter. Triggers rebuild from raw_recording with new filter applied.

Returns: {"mode": "notch60"}

BIDS Routes — routes/bids.py

GET/api/browse_dirs?path=/home/user

Lists subdirectories of a path for the folder-picker dialog. If no path is given, uses the current dataset_root or falls back to the home directory.

Returns: {"path": "...", "parent": "...", "dirs": ["subdir1", "subdir2", ...]}

GET/api/bids/datasets?root=/path/to/datasets

Scans a directory for BIDS datasets (subdirectories containing dataset_description.json). Sets dataset_root on the server state.

Returns: {"root": "...", "datasets": [{"dataset_id": "ds004100", "name": "HUP iEEG", "path": "...", "bids_version": "1.7.0", "n_subjects": 56}, ...]}

GET/api/bids/subjects?dataset=/path/to/ds004100

Lists subjects in a BIDS dataset. Parses participants.tsv and participants.json (field descriptions). Tags each subject with download status.

Returns: {"dataset": "...", "fields": {...}, "subjects": [{"participant_id": "sub-HUP117", "age": "35", "sex": "M", "downloaded": true}, ...]}

GET/api/bids/sessions?dataset=...&subject=sub-HUP117

Lists sessions for a subject. Detects available modalities per session.

Returns: {"sessions": [{"session_id": "ses-presurgery", "path": "...", "modalities": ["ieeg"]}, ...]}

GET/api/bids/recordings?dataset=...&subject=sub-HUP117&session=ses-presurgery

Lists recording files within a subject/session. Discovers recordings from both data files and JSON sidecars (marking undownloaded files as downloaded: false).

Returns: {"recordings": [{"filename": "..._ieeg.edf", "path": "...", "task": "ictal", "run": "01", "modality": "ieeg", "has_events": true, "has_channels": true, "downloaded": true}, ...]}

GET/api/bids/download?dataset=...&dataset_id=ds004100&subject=sub-HUP117

Streams download progress via Server-Sent Events (SSE). Uses the openneuro Python package to download data files for a single subject.

Events:

  • event: progress{"line": "Downloading sub-HUP117..."}
  • event: done{"success": true, "subject": "sub-HUP117"}
  • event: error{"message": "..."}

server.py — App Assembly

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()

# Register all route modules
app.include_router(recording.router)
app.include_router(signal.router)
app.include_router(metrics.router)
app.include_router(notch.router)
app.include_router(bids.router)

# Serve frontend from static/
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")

# Convenience shutdown for development
@app.post("/api/shutdown")
def shutdown(): os.kill(os.getpid(), signal.SIGTERM)

Running the Server

# From repo root:
conda activate concord
python -m concord_server.server

# Or:
python serve.py

# Server starts on http://localhost:8000
# Ctrl+C or browser button to stop