Data Containers¶
CobraBox provides a hierarchy of data container classes for different use cases.
Class Hierarchy¶
Data (general, no dimension requirements)
└── SignalData (requires 'time' dimension)
├── EEG # EEG data (type marker)
└── FMRI # fMRI data (type marker)
| Class | Requirements | Use Case |
|---|---|---|
Data |
None | General multidimensional data |
SignalData |
Must have 'time' dimension | Time-series analysis (EEG, fMRI) |
EEG |
Must have 'time' dimension | EEG-specific data |
FMRI |
Must have 'time' dimension | fMRI-specific data |
General Data Container¶
Creating Data Objects¶
From NumPy Arrays¶
import cobrabox as cb
import numpy as np
# 2D data with arbitrary dimensions
arr = np.random.normal(size=(100, 4))
data = cb.Data.from_numpy(
arr=arr,
dims=["time", "space"],
sampling_rate=100.0,
subjectID="sub-01",
groupID="control",
condition="baseline",
)
# 1D data (no time dimension)
arr_1d = np.random.normal(size=(50,))
data_1d = cb.Data.from_numpy(arr=arr_1d, dims=["channel"], subjectID="sub-01")
# data_1d.sampling_rate is None (no time dimension)
Requirements:
dimslength must match arrayndim- No mandatory dimensions
From xarray DataArray¶
import xarray as xr
# General Data with any dimensions
xr_data = xr.DataArray(
np.random.normal(size=(100, 4)),
dims=["x", "y"],
coords={"x": range(100), "y": ["A", "B", "C", "D"]},
)
data = cb.Data.from_xarray(xr_data, subjectID="sub-01")
SignalData (Time-Series Container)¶
SignalData is for time-series data and requires a 'time' dimension. It automatically transposes data to put time last for performance.
Creating SignalData Objects¶
import cobrabox as cb
import numpy as np
# Create time-series data
arr = np.random.normal(size=(1000, 64)) # time x channels
data = cb.SignalData.from_numpy(
arr=arr, dims=["time", "space"], sampling_rate=256.0, subjectID="sub-01", condition="task"
)
# Time dimension is automatically moved to last position
print(data.dims) # ('space', 'time')
print(data.shape) # (64, 1000)
Requirements:
- Must have a 'time' dimension
dimslength must match arrayndim- Time dimension will be transposed to last position
EEG and FMRI Subclasses¶
# EEG data (type marker)
eeg = cb.EEG.from_numpy(arr=arr, dims=["time", "space"], sampling_rate=256.0)
print(isinstance(eeg, cb.SignalData)) # True
print(isinstance(eeg, cb.Data)) # True
print(type(eeg) == cb.EEG) # True
# FMRI data (type marker)
fmri = cb.FMRI.from_numpy(
arr=fmri_arr,
dims=["time", "spaceX", "spaceY", "spaceZ"],
sampling_rate=0.5, # TR = 2s
)
Properties¶
Core Properties¶
# All containers share these properties
data.subjectID # Subject identifier
data.groupID # Group identifier
data.condition # Experimental condition
data.sampling_rate # Sampling rate in Hz (None if no time dimension)
data.history # List of applied operations
data.extra # Custom metadata dict
Data Access¶
A Data object is a thin wrapper. These accessors let you reach what's inside it — and ask the
usual questions about its shape — without reaching through .data yourself.
# Get at the wrapped object
data.data # Underlying xarray.DataArray
data.xarr # The same DataArray, under a name that can't be misread
data.numpy # Underlying numpy array, no copy
# Shape metadata, straight off the container
data.shape # (64, 1000) axis lengths
data.size # 64000 total element count
data.dims # ('space', 'time') axis names
data.sizes # {'space': 64, 'time': 1000} axis names → lengths
# Convert (these build a new object, unlike the accessors above)
data.to_numpy() # numpy array
data.to_pandas() # pandas DataFrame
Two distinctions are worth internalising, because both have bitten people:
| You want | Use | Not | Because |
|---|---|---|---|
| The numpy array | .numpy |
.data.data |
.data.data reads like a typo. .xarr.data says it out loud. |
| The axis lengths | .shape or .sizes |
.size |
.size is the element count, exactly as in numpy. |
.numpy and .xarr hand back the live underlying objects rather than copies, so they're free —
but Data is immutable, and writing into what they return breaks that guarantee. Use .to_numpy()
when you intend to modify the result. .sizes is the one exception: it returns a fresh dict, so
you can do what you like with it.
Which name should I reach for?
.shape, .size and .numpy mean exactly what they mean in numpy. .dims and .sizes mean
exactly what they mean in xarray. Nothing here invents a third convention — if you know either
library, the name you already expect is the right one.
Dimensions and Coordinates¶
Every Data object wraps an xarray.DataArray accessible as data.data. The DataArray carries
named dimensions and optional coordinate labels for each axis.
dims vs coords — these answer different questions:
dimsis just a tuple of axis names (e.g.("time", "space")) — which axes exist, and in what order. No values are attached.coordsmaps a dimension name to the actual label array for that axis (e.g.time: [0.0, 0.01, ...],space: ["E1", "E2", ...]) — the real tick values you'd use to select or display data. A dimension can exist without a coordinate (see the note below), so don't assumedimsimpliescoords.
The usual pattern: check dims to confirm an axis exists, then read coords to get what's on it.
Inspect dimensions¶
Dimension names and lengths come straight off the container — see Data Access above for the full set:
item = cb.load_dataset("dummy_chain")[0]
item.dims # e.g. ('space', 'time')
item.sizes # e.g. {'space': 4, 'time': 200}
# Which coordinates have labels attached? (still via the DataArray)
coords = list(item.data.coords) # e.g. ['time'] or ['time', 'space']
Get coordinate values as a list¶
# Space coordinate — returns a numpy array; call .tolist() for a plain list
space_labels = item.data.coords["space"].values.tolist()
# e.g. ['E1', 'E2', 'E3', 'E4'] or [0, 1, 2, 3] if no labels were set
# Time coordinate in seconds
time_array = item.data.coords["time"].values # numpy array
time_list = time_array.tolist() # Python list
Note: If you created
Datawithfrom_numpy()and did not supply coordinate labels forspace, the space dimension will have no coordinates at all —"space" not in item.data.coords. To attach labels, build anxr.DataArraywith explicitcoordsand usefrom_xarray().
Attach named coordinates (e.g., electrode labels)¶
import xarray as xr
import numpy as np
arr = np.random.normal(size=(200, 8)) # 200 time steps, 8 channels
labels = [f"E{i + 1}" for i in range(8)]
xr_arr = xr.DataArray(
arr,
dims=["time", "space"],
coords={
"time": np.arange(200) / 100.0, # seconds
"space": labels,
},
)
data = cb.Data.from_xarray(xr_arr, sampling_rate=100.0, subjectID="sub-01")
# Now space has labels:
data.data.coords["space"].values.tolist() # ['E1', 'E2', ..., 'E8']
Select by coordinate value¶
# Single channel
ch = data.data.sel(space="E3") # xr.DataArray, shape (200,)
# Multiple channels
subset = data.data.sel(space=["E1", "E5"]) # shape (2, 200) after transpose
# Time window (0.5 s – 1.0 s)
window = data.data.sel(time=slice(0.5, 1.0))
# To wrap the result back into a Data object:
data_subset = cb.Data.from_xarray(
subset.rename({"space": "space"}), # keep dims intact
subjectID=data.subjectID,
sampling_rate=data.sampling_rate,
)
Convert to numpy or pandas¶
arr = data.to_numpy() # plain numpy array, shape matches data.data.shape
df = data.to_pandas() # pandas DataFrame with MultiIndex from dimensions
# Access specific channels via pandas
df.xs("E1", level="space") # time-series for channel E1
Sampling Rate¶
General Data¶
For Data without a time dimension, sampling_rate is None:
For Data with a time dimension, sampling_rate can be provided or inferred:
# Explicit sampling rate
data = cb.Data.from_numpy(arr, dims=["time", "space"], sampling_rate=100.0)
# Or inferred from time coordinates (if time is in seconds)
coords = {"time": np.linspace(0, 1, 100), "space": ["E1", "E2"]}
xr_data = xr.DataArray(arr, dims=["time", "space"], coords=coords)
data = cb.Data.from_xarray(xr_data)
print(data.sampling_rate) # ~100.0 Hz (inferred)
SignalData¶
SignalData requires a time dimension, so sampling_rate may be provided or inferred, but will never be None if inference succeeds:
data = cb.SignalData.from_numpy(arr, dims=["time", "space"], sampling_rate=256.0)
print(data.sampling_rate) # 256.0
Immutability¶
All data containers are immutable. Attempting to modify them raises an error:
data = cb.Data.from_numpy(arr, dims=["time", "space"])
# This will fail:
data.subjectID = "sub-02" # AttributeError!
# Instead, create a new Data object:
new_data = cb.Data.from_xarray(
data.data, subjectID="sub-02", history=data.history, extra=data.extra
)
Extra Fields¶
The extra dict stores custom metadata:
data = cb.SignalData.from_numpy(
arr, dims=["time", "space"], extra={"notes": "Filtered 1-40 Hz", "bad_channels": ["E12", "E15"]}
)
# Access (returns a copy)
extra = data.extra
print(extra["notes"])
# To modify, create a new Data object:
new_extra = {**data.extra, "new_field": "value"}
# (typically done via features that accept extra parameter)
Conversion Methods¶
To NumPy¶
# Default: just data values
arr = data.to_numpy()
# Gorkastyle: (time, space, labels) - requires time and space dimensions
time, space, labels = data.to_numpy(style="gorkastyle")
To Pandas¶
Type Hints¶
Full type hints are provided:
from cobrabox import Data, SignalData, EEG, FMRI
def process_general(data: Data) -> Data:
"""Works with any Data container."""
return cb.Mean(dim="time").apply(data)
def process_timeseries(data: SignalData) -> Data:
"""Requires time-series data."""
return cb.LineLength().apply(data)
def process_eeg(data: EEG) -> EEG:
"""EEG-specific processing."""
return cb.LineLength().apply(data)
When to Use Each Container¶
- Use
Datawhen you have general multidimensional data without time (e.g., cross-sectional data, images without temporal dimension) - Use
SignalDatawhen you have time-series data (EEG, fMRI, other time-series) - Use
EEGor UseFMRIwhen you want explicit type markers for those modalities
Features that require time (like LineLength, Bandpower, SlidingWindow) are typed to accept SignalData, providing better IDE support and runtime validation.