# Convert EDF to PSG for Morpheus

Morpheus loads polysomnography recordings from a small HDF5 container named `.psg.h5`. This guide turns a standard `.edf` recording into that format with a short Python script, so your own data opens in Morpheus.

This guide is written to be followed by a person or by an automated agent. Run the blocks in order, edit only the two paths marked at the top of the first block, then run the verification in section 5. If the verification prints `OK`, Morpheus will load the file.

## 1. What a valid file must contain

A `.psg.h5` file is an HDF5 file. Morpheus requires exactly the following, and the verification in section 5 checks every point.

- The file name ends with `.psg.h5`.
- There is a group named `signals`. Morpheus refuses a file without it.
- Under `signals`, each channel is a 1-D dataset whose name contains no `/`, carrying a float attribute `sfreq` greater than 0 (its sampling rate in Hz). This is the only thing needed to draw waveforms.
- A group named `annotations` is optional. Each annotation is a subgroup holding a float64 dataset `intervals` of shape `[N, 2]` (each row is a `[start, end]` pair in seconds) and an int dataset `labels` of shape `[N]` whose values index into a string list stored in the `label_names` attribute. A subgroup whose name starts with `stage` becomes a hypnogram track.
- Time is in seconds and epochs are 30 seconds long. Intervals need not be one per epoch, so a single scored span such as `[0, 3600]` is fine.

A signals-only file already loads. The hypnogram and the auto-stage tools stay empty until a `stage` annotation is present.

## 2. Requirements

You need Python with three packages. Install them with pip.

```bash
pip install mne h5py numpy
```

`mne` reads the EDF, `h5py` writes the HDF5, `numpy` handles the arrays. Before converting, it helps to see what the recording holds.

```python
import mne
raw = mne.io.read_raw_edf('recording.edf', preload=False, verbose='error')
print(raw.ch_names)              # channel names, needed for optional channel picking
print(raw.info['sfreq'], 'Hz')   # the single rate MNE will read at
print(raw.annotations)           # non-empty if a hypnogram is embedded in the EDF+
```

## 3. Convert the signals

Save the script below as `edf_to_psg.py`, set the two paths, then run `python edf_to_psg.py`. It produces a signals-only `.psg.h5`. The script keeps every channel name valid and unique, so it never fails on names that clash after sanitizing.

```python
import mne, h5py, numpy as np

IN_EDF  = 'recording.edf'          # your EDF file
OUT_PSG = 'recording.psg.h5'       # output; the name must end with .psg.h5

# read_raw_edf returns all channels at one rate (raw.info['sfreq']); if the EDF
# mixes rates, MNE upsamples the slower channels to that rate. Data is in Volts.
raw = mne.io.read_raw_edf(IN_EDF, preload=True, verbose='error')
# To keep the file small, pick only the channels you need, e.g.:
# raw.pick(['EEG Fpz-Cz', 'EEG Pz-Oz', 'EOG horizontal'])
sfreq = float(raw.info['sfreq'])
data = raw.get_data()              # shape [n_channels, n_samples]

with h5py.File(OUT_PSG, 'w') as f:
  f.attrs['label'] = 'recording'   # shown as the file label; edit freely
  signals = f.create_group('signals')
  used = set()
  for i, name in enumerate(raw.ch_names):
    ch = name.replace('/', '_').strip() or f'ch{i}'   # '/' is illegal; never empty
    base, k = ch, 1
    while ch in used:                                 # keep names unique
      ch, k = f'{base}_{k}', k + 1
    used.add(ch)
    x = np.ascontiguousarray(data[i], dtype=np.float32)
    chunk = max(1, min(len(x), int(300 * sfreq)))     # store in 5-minute chunks
    ds = signals.create_dataset(ch, data=x, chunks=(chunk,))
    ds.attrs['sfreq'] = sfreq
    ds.attrs['unit'] = 'V'          # informational; MNE output is in Volts

print('wrote', OUT_PSG, 'with', len(raw.ch_names), 'channels at', sfreq, 'Hz')
```

## 4. Add a hypnogram (optional)

Morpheus draws a hypnogram from any annotation whose key starts with `stage`, and it prefers a key that contains `Ground-Truth`. Use section 4.1 when you hold scoring as a plain list, or section 4.2 when the hypnogram is an EDF+ annotation file. Both append to the file written in section 3.

Morpheus recognizes common spellings, so `W`, `Wake`, `N1`, `S1`, `Stage 1`, and `Sleep stage 2` all map to the right stage. The R&K stages `S3` and `S4` both fold into `N3`. You therefore do not need to rename your labels. Store them as they are and Morpheus maps them.

### 4.1 From a list of per-epoch stages

This block assumes `stages`, a list with one label per 30-second epoch, in order.

```python
import h5py, numpy as np
OUT_PSG = 'recording.psg.h5'

stages = ['W', 'W', 'N1', 'N2', 'N2', 'REM']    # one label per 30-s epoch, in order
stages = [str(s) for s in stages]               # plain str, so HDF5 stores them as text

names = sorted(set(stages))                     # label_names = the labels you used
idx = {n: i for i, n in enumerate(names)}
intervals = np.array([[k * 30, (k + 1) * 30] for k in range(len(stages))],
                     dtype=np.float64)
labels = np.array([idx[s] for s in stages], dtype=np.int32)

with h5py.File(OUT_PSG, 'a') as f:
  grp = f.require_group('annotations').create_group('stage Ground-Truth')
  grp.create_dataset('intervals', data=intervals)
  grp.create_dataset('labels', data=labels)
  grp.attrs['label_names'] = names
```

### 4.2 From an EDF+ annotation file

Sleep hypnograms often ship as a separate EDF+ file, for example the `*-Hypnogram.edf` in SleepEDF. Read its annotations and store each scored span directly. No epoch expansion is needed, since Morpheus expands spans into epochs when it draws them.

```python
import mne, h5py, numpy as np
OUT_PSG = 'recording.psg.h5'

ann = mne.read_annotations('recording-Hypnogram.edf')   # onset, duration in seconds
desc = [str(d) for d in ann.description]                 # MNE gives numpy strings; make them plain str
keep = [i for i, d in enumerate(desc) if d]              # drop empty descriptions
names = sorted({desc[i] for i in keep})                  # label_names = raw descriptions
idx = {d: i for i, d in enumerate(names)}
intervals = np.array([[ann.onset[i], ann.onset[i] + ann.duration[i]] for i in keep],
                     dtype=np.float64)
labels = np.array([idx[desc[i]] for i in keep], dtype=np.int32)

with h5py.File(OUT_PSG, 'a') as f:
  grp = f.require_group('annotations').create_group('stage Ground-Truth')
  grp.create_dataset('intervals', data=intervals)
  grp.create_dataset('labels', data=labels)
  grp.attrs['label_names'] = names
```

## 5. Verify the output

Run this after converting. It checks every requirement from section 1, so a pass means Morpheus will load the file. An automated agent should treat a failed assertion as a conversion error and fix it before continuing.

```python
import h5py, numpy as np
OUT_PSG = 'recording.psg.h5'

def verify(path):
  assert path.endswith('.psg.h5'), 'name must end with .psg.h5'
  with h5py.File(path, 'r') as f:
    assert 'signals' in f, "missing 'signals' group"
    chans = list(f['signals'].keys())
    assert chans, 'no channels under signals/'
    for ch in chans:
      ds = f['signals'][ch]
      assert '/' not in ch, f"channel name '{ch}' contains '/'"
      assert ds.ndim == 1, f"'{ch}': signal must be 1-D"
      assert 'sfreq' in ds.attrs and float(ds.attrs['sfreq']) > 0, \
        f"'{ch}': missing or non-positive sfreq"
    n_stage = 0
    for key in f.get('annotations', {}):
      g = f['annotations'][key]
      if g.attrs.get('is_event', False):
        continue
      iv, lab = g['intervals'][:], g['labels'][:]
      assert iv.ndim == 2 and iv.shape[1] == 2, f"'{key}': intervals must be [N, 2]"
      assert len(lab) == len(iv), f"'{key}': labels and intervals differ in length"
      ln = list(g.attrs.get('label_names', []))
      assert len(ln) > 0, f"'{key}': label_names attribute is missing"
      assert len(lab) == 0 or (0 <= int(lab.min()) and int(lab.max()) < len(ln)), \
        f"'{key}': a label index is out of range for label_names"
      n_stage += key.startswith('stage')
  print(f'OK: {len(chans)} channels, {n_stage} stage annotation(s) ->', chans)

verify(OUT_PSG)
```

## 6. Open it in Morpheus

Drag the `.psg.h5` onto the Morpheus window, or use the open button. The channels appear as waveforms, and the hypnogram track shows if you added one.

## 7. Notes

- Every channel needs its own `sfreq` attribute. The script copies MNE's single rate onto all of them.
- Channel names cannot contain `/`. The script replaces it with `_` and keeps the result unique.
- The `unit` attribute is informational. Morpheus auto-scales each channel and the auto-stage model normalizes internally, so the absolute scale changes neither. MNE reports biopotential channels in Volts.
- `float32` keeps full precision. Switch to `np.float16` to roughly halve the file when size matters.
- Picking only the channels you need before `get_data` both shrinks the file and avoids upsampling slow channels such as SpO2 to the common rate.

## 8. Alternative, the hypnomics package

If you have the `hypnomics` package installed, `PSG.from_raw` writes the same file in one call, and it enforces the same rules.

```python
from hypnomics.protocol.psg import PSG

signal_dict = {name.replace('/', '_'): (data[i].astype('float32'), sfreq, 'V')
               for i, name in enumerate(raw.ch_names)}
PSG.from_raw(OUT_PSG, signal_dict, label='recording')
```
