Loading a session in Python¶
Nothing to install beyond NumPy: .npy is numpy.load, and session.xml is
xml.etree.ElementTree from the standard library. Matplotlib is used only for the plots
at the end.
The short version¶
import numpy as np
import xml.etree.ElementTree as ET
from pathlib import Path
session = Path("~/sessions/TriggeredAvg_2026-08-24_143107").expanduser()
root = ET.parse(session / "session.xml").getroot()
fs = float(root.get("sample_rate_hz"))
pre = int(root.get("pre_samples"))
averages = np.load(session / "arrays" / "averages.npy") # (sources, channels, samples)
counts = np.load(session / "arrays" / "trial_counts.npy") # (sources,)
t_ms = np.load(session / "arrays" / "time_ms.npy") # (samples,), trigger at 0
conditions = [s.get("name") for s in root.iter("TRIGGERSOURCE")]
channels = [c.get("name") for c in root.find("CHANNELS")]
print(f"{averages.shape[0]} conditions, {averages.shape[1]} channels, {fs} Hz")
for name, n in zip(conditions, counts):
print(f" {name}: {n} trials")
averages, standard_deviations, sums and sum_squares are all
(sources, channels, samples). The source axis is in the order the <TRIGGERSOURCE>
elements appear; the channel axis is in the order the <CHANNEL> elements appear.
A reusable loader¶
Small enough to paste into an analysis script.
"""Read an Event-Triggered Analysis session directory."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
import numpy as np
@dataclass(frozen=True)
class Condition:
"""One trigger source: an experimental condition."""
name: str
line: int
colour: str
arm_pattern: str
cancel_pattern: str
commit_pattern: str
pending_timeout_ms: int
angle_deg: float | None = None # Bar Mapper only
@dataclass
class Session:
directory: Path
root: ET.Element = field(repr=False)
# --- opening ----------------------------------------------------------
@classmethod
def open(cls, directory) -> "Session":
directory = Path(directory)
manifest = directory / "session.xml"
if not manifest.is_file():
raise FileNotFoundError(f"{directory} is not a session directory")
root = ET.parse(manifest).getroot()
if root.tag != "EVENT_TRIGGERED_SESSION":
raise ValueError(f"{manifest} is not a session manifest")
version = int(root.get("format_version", "0"))
if version != 1:
raise ValueError(f"unsupported session format_version {version}")
return cls(directory=directory, root=root)
# --- provenance and geometry -----------------------------------------
@property
def plugin(self) -> str:
return self.root.get("plugin", "")
@property
def plugin_version(self) -> str:
return self.root.get("plugin_version", "")
@property
def saved_at(self) -> str:
return self.root.get("saved_at", "")
@property
def is_demo_data(self) -> bool:
return self.root.get("demo_data", "0") == "1"
@property
def sample_rate_hz(self) -> float:
return float(self.root.get("sample_rate_hz", "0"))
@property
def pre_samples(self) -> int:
return int(self.root.get("pre_samples", "0"))
@property
def post_samples(self) -> int:
return int(self.root.get("post_samples", "0"))
@property
def num_samples(self) -> int:
return self.pre_samples + self.post_samples
# --- what the axes mean ----------------------------------------------
@cached_property
def channels(self) -> list[tuple[int, str]]:
"""(global index, name) per channel, in accumulator order."""
element = self.root.find("CHANNELS")
if element is None:
return []
return [(int(c.get("index", "-1")), c.get("name", "")) for c in element]
@cached_property
def conditions(self) -> list[Condition]:
"""One per trigger source, in the order of the arrays' first axis."""
settings = self.root.find("CUSTOM_PARAMETERS")
if settings is None:
return []
# Sweep angles are a parallel list matched by position, not attributes on
# the sources.
angles: dict[int, float] = {}
for element in settings.findall("SWEEPANGLE"):
if "angleDeg" in element.attrib:
angles[int(element.get("index", "-1"))] = float(element.get("angleDeg"))
return [
Condition(
name=element.get("name", ""),
line=int(element.get("line", "-1")),
colour=element.get("colour", ""),
arm_pattern=element.get("armPattern", ""),
cancel_pattern=element.get("cancelPattern", ""),
commit_pattern=element.get("commitPattern", ""),
pending_timeout_ms=int(element.get("pendingTimeoutMs", "5000")),
angle_deg=angles.get(i),
)
for i, element in enumerate(settings.findall("TRIGGERSOURCE"))
]
@property
def time_ms(self) -> np.ndarray:
"""Trial time axis in milliseconds, trigger at 0, read from the file."""
if "time_ms" in self.array_names:
return self.array("time_ms")
n = np.arange(self.num_samples)
return 1000.0 * (n - self.pre_samples) / self.sample_rate_hz
# --- arrays -----------------------------------------------------------
@cached_property
def _index(self) -> dict[str, dict[str, str]]:
element = self.root.find("ARRAYS")
return {} if element is None else {a.get("name"): a.attrib for a in element}
@property
def array_names(self) -> list[str]:
return sorted(self._index)
def shape(self, name: str) -> tuple[int, ...]:
"""The stored shape, without reading the array."""
text = self._index[name]["shape"]
return tuple(int(part) for part in text.split(",")) if text else ()
def array(self, name: str) -> np.ndarray:
if name not in self._index:
raise KeyError(f"{name!r} is not in this session ({self.array_names})")
return np.load(self.directory / self._index[name]["file"])
def __getitem__(self, name: str) -> np.ndarray:
return self.array(name)
# --- figures ----------------------------------------------------------
@property
def figures(self) -> list[Path]:
return sorted((self.directory / "figures").glob("*.png"))
Working with it¶
s = Session.open("~/sessions/TriggeredAvg_2026-08-24_143107")
print(s.plugin, s.plugin_version, s.saved_at)
print(s.array_names)
averages = s["averages"] # (sources, channels, samples)
counts = s["trial_counts"] # (sources,)
t = s.time_ms
Standard error of the mean¶
standard_deviations is the population standard deviation over trials. Divide by the
square root of the trial count for the SEM, guarding against a condition with no trials,
which is written as zeros:
sd = s["standard_deviations"] # (sources, channels, samples)
n = s["trial_counts"].astype(float) # (sources,)
sem = np.full_like(sd, np.nan)
ok = n > 0
sem[ok] = sd[ok] / np.sqrt(n[ok])[:, None, None]
Rebuilding the average from the resumable state¶
averages is written for convenience, but it is derived. The state is sums and
trial_counts:
sums = s["sums"]
n = s["trial_counts"].astype(float)
with np.errstate(invalid="ignore", divide="ignore"):
averages = sums / n[:, None, None]
averages[n == 0] = 0.0 # the convention the writer uses
Population SD from sum_squares. The maximum matters: E[x²] − E[x]² goes slightly
negative in float32 for a nearly flat trace.
with np.errstate(invalid="ignore", divide="ignore"):
mean_of_squares = s["sum_squares"] / n[:, None, None]
variance = np.maximum(mean_of_squares - averages**2, 0.0)
sd = np.sqrt(variance)
sd[n == 0] = 0.0
Selecting a condition by name¶
Never by index typed in by hand — the order is the trigger table's, and it changes when a condition is added:
names = [c.name for c in s.conditions]
i = names.index("Attend in")
trace = s["averages"][i, 0] # first channel of that condition
Plotting an evoked average¶
import matplotlib.pyplot as plt
s = Session.open("~/sessions/TriggeredAvg_2026-08-24_143107")
averages = s["averages"]
sd = s["standard_deviations"]
n = s["trial_counts"].astype(float)
t = s.time_ms
channel = 0
fig, ax = plt.subplots(figsize=(7, 4))
for i, condition in enumerate(s.conditions):
if n[i] == 0:
continue
mean = averages[i, channel]
sem = sd[i, channel] / np.sqrt(n[i])
line, = ax.plot(t, mean, label=f"{condition.name} (n={int(n[i])})")
ax.fill_between(t, mean - sem, mean + sem, alpha=0.2, color=line.get_color())
ax.axvline(0.0, color="0.4", lw=0.8, ls="--")
ax.set_xlabel("Time from trigger (ms)")
ax.set_ylabel("Amplitude (µV)")
ax.set_title(f"{s.channels[channel][1]} — {s.plugin}")
ax.legend()
fig.tight_layout()
plt.show()
Receptive-field sessions¶
The Bar Mapper writes everything above — its conditions are directions — plus the finished maps.
s = Session.open("~/sessions/RFBarMapper_2026-08-24_151122")
maps = s["maps"] # (channels, pixels, pixels)
estimates = s["map_estimates"] # (channels, 7)
valid = s["map_valid"].astype(bool) # (channels,)
indices = s["map_channel_indices"] # (channels,)
# Read the column names from the manifest rather than hard-coding the order.
fields = s.root.get("map_estimate_fields").split(",")
estimate = dict(zip(fields, estimates[0]))
print(estimate["equivalent_diameter_deg"], estimate["centre_x_deg"])
The map's coordinates¶
The manifest carries the geometry:
pixels = int(s.root.get("map_pixels"))
deg_per_pixel = float(s.root.get("map_degrees_per_pixel"))
centre_x = float(s.root.get("map_centre_x_deg"))
centre_y = float(s.root.get("map_centre_y_deg"))
half = 0.5 * pixels * deg_per_pixel
extent = (centre_x - half, centre_x + half, centre_y - half, centre_y + half)
Rows run top to bottom, visual-field y runs upwards
The flip is applied once, when the map is built. Plot with origin="upper" — the
default — and the picture matches the canvas.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(5, 5))
im = ax.imshow(maps[0], cmap="jet", origin="upper", extent=extent)
ax.set_xlabel("Visual field x (deg)")
ax.set_ylabel("Visual field y (deg)")
ax.set_title(f"{s.channels[0][1]} — RF {estimate['equivalent_diameter_deg']:.2f}°")
fig.colorbar(im, ax=ax, label="z")
plt.show()
The per-direction traces behind the map¶
When the map looks wrong the cause is usually visible in the direction averages. The angle
each condition stands for is on the Condition:
directions = [(c.angle_deg, i) for i, c in enumerate(s.conditions) if c.angle_deg is not None]
for angle, i in sorted(directions):
print(f"{angle:6.1f}° n={s['trial_counts'][i]:4d} {s.conditions[i].name}")
A direction with no angle contributes nothing to the map — angle_deg is None, not
0.0.
Things worth knowing¶
| A condition with no trials is zeros, not NaN | trial_counts is what distinguishes it from a real zero. Check it before dividing. |
demo_data="1" means simulated data |
Session.is_demo_data. |
| The parameter values are not in the session | Only the trial geometry, the channel list, the trigger table and — for the Bar Mapper — the map geometry and sweep angles. See Format. |
| The single-trial ring is not saved | Only the accumulators; individual trials are a display buffer. |
| Maps are an output | The Bar Mapper recomputes them from the accumulators on load rather than using the stored ones. |