Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies = [
"packaging>=21.0", # version comparison in the in-app updater
"ryvencore>=0.4.0",
"pandas>=2.0.0",
"openpyxl>=3.1.0", # Session Review time-bin export: a sheet per measure
"bleak>=0.21.0", # Bluetooth LE (async, cross-platform: WinRT/CoreBluetooth/BlueZ)
# PyQt6 is intentionally absent from core: Raspberry Pi uses system-installed PyQt6
# (sudo apt install python3-pyqt6). PC installs use the [pc] optional group.
Expand Down
22 changes: 22 additions & 0 deletions src/glider/analysis/behavior/session_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,28 @@ def segment_stats(
threshold_unit=unit,
)

def bout_onsets(self, start_frame: int, end_frame: int) -> dict[str, int]:
"""Bouts of each behaviour that *start* in ``[start_frame, end_frame]``.

Not :meth:`segment_stats`' ``n_bouts``, which counts every bout the
window touches: one crossing a bin edge would count in both bins, and
the bins would add up to more bouts than the session had. Counted by
onset, a bout already under way at ``start_frame`` belongs to the bin
before, and the bins sum to the whole.
"""
start_frame, end_frame = sorted((int(start_frame), int(end_frame)))
rows = np.where((self.frames >= start_frame) & (self.frames <= end_frame))[0]
counts: dict[str, int] = {}
if not len(rows):
return counts
previous = self.labels[rows[0] - 1] if rows[0] > 0 else None
for i in rows:
label = self.labels[i]
if label != previous and label and label != UNSCORED:
counts[label] = counts.get(label, 0) + 1
previous = label
return counts

def _bout_table(self, labels, compute_intervals, compute_bouts, *, frames) -> pd.DataFrame:
"""Per-state bout tally for a label run, reusing the ethogram primitives.

Expand Down
50 changes: 49 additions & 1 deletion src/glider/analysis/epochs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from __future__ import annotations

import math
import re
import statistics
from collections import Counter
from collections.abc import Iterable
Expand All @@ -23,6 +24,7 @@
"Metric",
"default_metrics",
"metric_catalog",
"metric_sheets",
"metric_text",
"metric_value",
"missing_reason",
Expand Down Expand Up @@ -80,7 +82,8 @@ def _title(name: str) -> str:
def metric_catalog(rows: Iterable[dict]) -> list[Metric]:
"""Every metric the rows can answer, in reading order.

Movement; each behaviour as seconds and as a share of the range; each
Movement; each behaviour as seconds, as a share of the range and as bouts
begun in it; each
zone; each device; pup retrieval where a session was scored for it;
then the thresholds each session was scored with. Freezing and darting
are always offered -- every row carries them.
Expand All @@ -94,6 +97,8 @@ def metric_catalog(rows: Iterable[dict]) -> list[Metric]:
out += [
Metric(f"{state}_s", f"{_title(state)} (s)", 2),
Metric(f"{state}_pct", f"{_title(state)} %", 1),
# Counted where each bout starts, so bins sum to the session.
Metric(f"{state}_bouts", f"{_title(state)} bouts", 0),
]
for zone in zones:
out += [
Expand Down Expand Up @@ -274,3 +279,46 @@ def wide_frame(tidy: pd.DataFrame, metrics: list[str]) -> pd.DataFrame:
for key in metrics:
out[f"{name}__{key}"] = block[key]
return out.reset_index()


#: Characters Excel refuses in a sheet name, and its length limit.
_SHEET_FORBIDDEN = re.compile(r"[\[\]:*?/\\]")
_SHEET_MAX = 31


def _sheet_name(label: str, taken: set[str]) -> str:
"""``label`` as a legal, unique Excel sheet name."""
base = _SHEET_FORBIDDEN.sub("-", label).strip("' ") or "Measure"
name, n = base[:_SHEET_MAX], 1
while name.lower() in taken: # Excel compares sheet names case-blind
n += 1
suffix = f" ({n})"
name = base[: _SHEET_MAX - len(suffix)] + suffix
taken.add(name.lower())
return name


def metric_sheets(
tidy: pd.DataFrame, metrics: list[Metric], *, reserved: Iterable[str] = ()
) -> list[tuple[str, Metric, pd.DataFrame]]:
"""``(sheet name, metric, table)`` per metric: a row per session, a column per range.

For a workbook with a sheet per measure, the layout a stats package or a
plot wants. Sessions and ranges keep the order ``tidy`` has them in. A
metric no session has a value for gets no sheet: an empty one is clutter.
Sheet names are legal in Excel and unique, avoiding ``reserved``.
"""
ranges = list(dict.fromkeys(tidy["range"]))
sessions = tidy.drop_duplicates("session")[["session", "group"]]
taken = {name.lower() for name in reserved}
sheets: list[tuple[str, Metric, pd.DataFrame]] = []
for metric in metrics:
if metric.key not in tidy or tidy[metric.key].isna().all():
continue
table = tidy.pivot(index="session", columns="range", values=metric.key)
table = table.reindex(index=sessions["session"], columns=ranges)
table.columns.name = None
table = table.reset_index()
table.insert(1, "group", sessions["group"].to_numpy())
sheets.append((_sheet_name(metric.label, taken), metric, table))
return sheets
82 changes: 73 additions & 9 deletions src/glider/gui/behavior/analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
CURRENT,
Epoch,
metric_catalog,
metric_sheets,
threshold_text,
tidy_frame,
time_bins,
Expand Down Expand Up @@ -139,6 +140,16 @@ def _short_path(path: Path, keep: int = 3) -> str:
_EXCEL_CSV = "utf-8-sig"


def _write_workbook(path: Path, sheets: dict) -> None:
"""One sheet per table, in order, with the header row frozen."""
import openpyxl # noqa: F401 # ImportError here, not deep inside pandas
import pandas as pd

with pd.ExcelWriter(path, engine="openpyxl") as writer:
for name, table in sheets.items():
table.to_excel(writer, sheet_name=name, index=False, freeze_panes=(1, 0))


def _unique(ids: list[str]) -> list[str]:
"""Session ids made unique, later repeats suffixed `` (2)``, `` (3)``.

Expand Down Expand Up @@ -243,7 +254,7 @@ def _select_all_text() -> str:


def _zero_fill_across_epochs(rows_by_epoch: dict[str, list[dict]]) -> dict[str, list[dict]]:
"""Zero-fill ``<state>_s`` using the states seen in *any* epoch, not just one.
"""Zero-fill ``<state>_s`` and ``_bouts`` using the states seen in *any* epoch.

``range_rows`` already zero-fills a behaviour some session showed within
that one call's own rows (a measurement, not a gap -- see its comment),
Expand All @@ -261,6 +272,7 @@ def _zero_fill_across_epochs(rows_by_epoch: dict[str, list[dict]]) -> dict[str,
if not row.get("outside"):
for state in states:
row.setdefault(f"{state}_s", 0.0)
row.setdefault(f"{state}_bouts", 0)
return rows_by_epoch


Expand Down Expand Up @@ -2763,6 +2775,7 @@ def range_rows(self, start_s: float, end_s: float) -> list[dict]:
if not row.get("outside"):
for state in states:
row.setdefault(f"{state}_s", 0.0)
row.setdefault(f"{state}_bouts", 0)
# ponytail: cleared wholesale past 64 ranges; an LRU if dragging
# with the epoch table open ever shows up in a profile.
if len(self._cohort_cache) >= 64:
Expand Down Expand Up @@ -2836,6 +2849,7 @@ def total(state, table=by_state):
f"{state}_s": float(seconds)
for state, seconds in zip(stats.bouts["state"], stats.bouts["total_s"], strict=True)
},
**{f"{state}_bouts": n for state, n in view.bout_onsets(start, end).items()},
**self._zone_columns(start, end, view),
**self._hardware_columns(timeline, view.fps, start, end),
**self._retrieval_columns(index, start, end),
Expand Down Expand Up @@ -3118,14 +3132,24 @@ def _cohort_span(self) -> tuple[float, float] | None:
return min(s for s, _ in spans), max(e for _, e in spans)

def _run_bins_dialog(self, minutes: float, *, has_selection: bool):
"""``(bin_s, whole_session, shape)`` as chosen, or None if cancelled."""
from glider.gui.review.time_bins_dialog import TimeBinsDialog
"""``(bin_s, whole_session, shape)`` as chosen, or None if cancelled.

dialog = TimeBinsDialog(minutes, has_selection=has_selection, parent=self)
``shape`` is ``"tidy"``, ``"wide"``, or ``"sheets"`` for a workbook
with a sheet per measure; the tick is remembered.
"""
from glider.gui.review.time_bins_dialog import SHEETS, TimeBinsDialog

settings = _settings()
per_sheet = str(settings.value("review/bin_sheets", "false")).lower() == "true"
dialog = TimeBinsDialog(
minutes, has_selection=has_selection, per_sheet=per_sheet, parent=self
)
try:
if dialog.exec() != QDialog.DialogCode.Accepted:
return None
return dialog.bin_seconds(), dialog.whole_session(), dialog.layout_shape()
shape = dialog.layout_shape()
settings.setValue("review/bin_sheets", "true" if shape == SHEETS else "false")
return dialog.bin_seconds(), dialog.whole_session(), shape
finally:
dialog.deleteLater()

Expand All @@ -3141,8 +3165,11 @@ def bin_frame(self, start_s: float, end_s: float, bin_s: float, shape: str):
if not bins:
return None
rows = _zero_fill_across_epochs({b.key: self.range_rows(b.start_s, b.end_s) for b in bins})
metrics = [m.key for m in metric_catalog(r for block in rows.values() for r in block)]
catalog = metric_catalog(r for block in rows.values() for r in block)
metrics = [m.key for m in catalog]
tidy = tidy_frame(bins, rows, metrics)
if shape == "sheets":
return self._bin_workbook(bins, tidy, catalog)
if shape == "wide":
return wide_frame(tidy, metrics)
edges = {b.name: (b.start_s, b.end_s) for b in bins}
Expand All @@ -3151,6 +3178,28 @@ def bin_frame(self, start_s: float, end_s: float, bin_s: float, shape: str):
tidy.insert(4, "bin_end_s", [edges[name][1] for name in tidy["bin"]])
return tidy

@staticmethod
def _bin_workbook(bins, tidy, catalog) -> dict:
"""Sheet name -> table: a sheet per measure, then what the bins and sheets are."""
import pandas as pd

sheets = metric_sheets(tidy, catalog, reserved=("Bins", "Measures"))
measures = pd.DataFrame(
{
"sheet": [name for name, _, _ in sheets],
"measure": [metric.label for _, metric, _ in sheets],
"column": [metric.key for _, metric, _ in sheets],
}
)
about = pd.DataFrame(
{
"bin": [b.name for b in bins],
"start_s": [b.start_s for b in bins],
"end_s": [b.end_s for b in bins],
}
)
return {name: table for name, _, table in sheets} | {"Bins": about, "Measures": measures}

def _export_bins(self) -> None:
"""The cohort cut into fixed-width time bins: every metric, per bin."""
if not self._cohort:
Expand All @@ -3177,11 +3226,15 @@ def _export_bins(self) -> None:
# bin edge so every bin is whole but the last.
start_s = math.floor(min(0.0, start_s) / bin_s) * bin_s
root = self._cohort_root or self._session_folder(max(0, self._shown))
workbook = shape == "sheets"
suffix, kind = (
(".xlsx", "Excel Workbook (*.xlsx)") if workbook else (".csv", "CSV Files (*.csv)")
)
path, _ = QFileDialog.getSaveFileName(
self,
"Export time bins",
str(root / f"time_bins_{bin_s / 60.0:g}min_{shape}.csv"),
"CSV Files (*.csv)",
str(root / f"time_bins_{bin_s / 60.0:g}min_{shape}{suffix}"),
kind,
)
if not path:
return
Expand All @@ -3194,7 +3247,18 @@ def _export_bins(self) -> None:
QMessageBox.information(self, "Export time bins", "The span holds no whole frame.")
return
try:
frame.to_csv(path, index=False, encoding=_EXCEL_CSV)
if workbook:
_write_workbook(Path(path), frame)
else:
frame.to_csv(path, index=False, encoding=_EXCEL_CSV)
except ImportError:
QMessageBox.critical(
self,
"Export time bins",
"Writing Excel workbooks needs openpyxl, which is not installed.\n\n"
"Install it (pip install openpyxl), or untick one sheet per measure.",
)
return
except OSError as e:
QMessageBox.critical(self, "Export time bins", f"Could not write {path}: {e}")
return
Expand Down
21 changes: 17 additions & 4 deletions src/glider/gui/review/time_bins_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@
from __future__ import annotations

from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
)

__all__ = ["BIN_PRESETS_MIN", "TimeBinsDialog"]
__all__ = ["BIN_PRESETS_MIN", "SHEETS", "TimeBinsDialog"]

#: Offered in the bin box's drop-down; any other width can be typed.
BIN_PRESETS_MIN = (1.0, 3.0, 5.0, 10.0)

WHOLE, SELECTION = "whole", "selection"
#: The layout of a workbook with a sheet per measure, in place of tidy or wide.
SHEETS = "sheets"


class TimeBinsDialog(QDialog):
"""Bin width, the span to bin, and the CSV's shape."""
"""Bin width, the span to bin, and the file's shape."""

def __init__(self, minutes: float, *, has_selection: bool, parent=None):
def __init__(
self, minutes: float, *, has_selection: bool, per_sheet: bool = False, parent=None
):
super().__init__(parent)
self.setWindowTitle("Export time bins")
form = QFormLayout(self)
Expand All @@ -47,13 +52,21 @@ def __init__(self, minutes: float, *, has_selection: bool, parent=None):
self.shape = QComboBox()
self.shape.addItem("Tidy: a row per session per bin", "tidy")
self.shape.addItem("Wide: a row per session", "wide")
self.per_sheet = QCheckBox("One sheet per measure (Excel workbook)")
self.per_sheet.setToolTip(
"Each behaviour, zone and distance measure on its own sheet: "
"a row per session, a column per bin."
)
self.per_sheet.toggled.connect(lambda on: self.shape.setEnabled(not on))
self.per_sheet.setChecked(per_sheet)
self._sync_preset(minutes)
self.preset.activated.connect(self._preset_picked)
self.bin_width.valueChanged.connect(self._sync_preset)
form.addRow("Bin width", self.preset)
form.addRow("", self.bin_width)
form.addRow("Span", self.span)
form.addRow("Layout", self.shape)
form.addRow("", self.per_sheet)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
Expand All @@ -77,4 +90,4 @@ def whole_session(self) -> bool:
return self.span.currentData() == WHOLE

def layout_shape(self) -> str:
return self.shape.currentData()
return SHEETS if self.per_sheet.isChecked() else self.shape.currentData()
28 changes: 28 additions & 0 deletions tests/unit/analysis/behavior/test_session_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,3 +832,31 @@ def test_video_suffixes(self):

assert all(is_video(f"a{s}") for s in (".mp4", ".AVI", ".mov", ".mkv"))
assert not is_video("a.csv") and not is_video("a")


class TestBoutOnsets:
# groom 0-3, rear 4-5, groom 6-8, unscored 9, groom 10-11
LABELS = ["groom"] * 4 + ["rear"] * 2 + ["groom"] * 3 + ["unscored"] + ["groom"] * 2

def _view(self):
from glider.analysis.ethogram import UNSCORED

labels = [UNSCORED if lab == "unscored" else lab for lab in self.LABELS]
return SessionView(labels=labels, frames=np.arange(len(labels)), fps=1.0)

def test_every_bout_that_starts_in_the_range(self):
assert self._view().bout_onsets(0, 11) == {"groom": 3, "rear": 1}

def test_a_bout_under_way_at_the_start_is_not_counted(self):
# Frame 2 is the middle of the first groom bout.
assert self._view().bout_onsets(2, 5) == {"rear": 1}

def test_adjacent_ranges_sum_to_the_whole(self):
view = self._view()
whole = view.bout_onsets(0, 11)
parts = [view.bout_onsets(a, b) for a, b in ((0, 2), (3, 6), (7, 11))]
for state, n in whole.items():
assert sum(p.get(state, 0) for p in parts) == n

def test_unscored_frames_are_not_a_behaviour(self):
assert "unscored" not in self._view().bout_onsets(0, 11)
Loading
Loading