OSCR

The Crunchometer, a low-cost, open-source acoustic analysis of feeding microstructure.

Code ↔ Paper

18 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 18 matches
  1. [1] § Results › The Crunchometer: an open-source sound-based method for studying the microstructure of feeding behavior ↔ crunchometer_python/crunchometer/core/analyzer.py, lines 38–87 · score 0.79 · dynamic thresholds, fixed threshold, frequency band, feeding behavior, crunch, 950 Hz
  2. [2] § Materials and methods › SVM model of the Crunchometer: automatic detection of biting from audio ↔ Xsembles/Linkage_JP.m, lines 1–60 · score 0.76 · Euclidean distances, hierarchical clustering, linkage, Ward, algorithm, vector
  3. [3] § Materials and methods › Calcium imaging experiments and analysis ↔ Xsembles_2P.m, lines 14–43 · score 0.70 · peak signal, noise ratio, motion correction, algorithm, Raw, neuronal
  4. [4] § Materials and methods › Tracking trajectory and running speed ↔ crunchometer_python/motion_energy.py, lines 14–89 · score 0.67 · binary mask, mass center, grayscale, speed, subtracted, mp4
  5. [5] § Materials and methods › Statistical analysis ↔ crunchometer_python/group_analysis.py, lines 456–507 · score 0.67 · Mann Whitney, post hoc, way ANOVA, metrics, Crunchometer
  6. [6] § Materials and methods › Calcium imaging experiments and analysis ↔ Deconvolution Caiman/foopsi_oasisAR2.m, lines 1–102 · score 0.66 · fluorescence traces, Neural activity, Calcium imaging, signal, events
  7. [7] § Materials and methods › Tracking trajectory and running speed ↔ crunchometer_python/crunchometer/core/tracker.py, lines 572–702 · score 0.65 · darker, Foreground, grayscale, morphological, static, speed
  8. [8] § Results › The Crunchometer: an open-source sound-based method for studying the microstructure of feeding behavior ↔ crunchometer_python/crunchometer/core/classifier.py, lines 23–53 · score 0.64 · high fat diet, gnawing behavior, motion energy, Feeding bouts, optional, noise
  9. [9] § Results › The Crunchometer system distinguishes meal patterns of fed and fasted mice ↔ crunchometer_python/group_analysis.py, lines 1121–1184 · score 0.62 · Inter Bout Interval, cumulative feeding, feeding rate, hour, SEM, IBI
  10. [10] § Results › Semaglutide suppresses feeding and reduces preference for a high-fat diet ↔ crunchometer_python/group_analysis.py, lines 456–507 · score 0.61 · Mann Whitney, post hoc, way ANOVA, treatment, Crunchometer
  11. [11] § Materials and methods › Identification of Chow vs. HFD consumption ↔ crunchometer_python/motion_energy.py, lines 167–226 · score 0.61 · Motion energy, HFD ROI, Chow ROI, ROIs, classified, threshold
  12. [12] § Results › The Crunchometer: an open-source sound-based method for studying the microstructure of feeding behavior ↔ crunchometer_python/crunchometer/core/resnet_detector.py, lines 1–39 · score 0.58 · sound bite events, chewing, spectrum, feeding bout, pipeline, noise
  13. [13] § Materials and methods › SVM model of the Crunchometer: automatic detection of biting from audio ↔ crunchometer_python/crunchometer/core/svm_detector.py, lines 252–335 · score 0.57 · frequency resolution, spectral, nfft, SVM, segments, overlapping
  14. [14] § Results › Event-locked alignment of calcium recordings to acoustically detected feeding bouts via the Crunchometer ↔ Xsembles_2P_Viewer.m, lines 833–920 · score 0.57 · OFFsemble, ONsemble, population, ensemble, activity, events
  15. [15] § Materials and methods › Signal processing pipeline of the Crunchometer ↔ crunchometer_python/crunchometer/core/svm_detector.py, lines 252–335 · score 0.55 · power spectrum, audio signals, overlap, spectrogram, window, bin
  16. [16] § Results › The Crunchometer system distinguishes meal patterns of fed and fasted mice ↔ crunchometer_python/group_analysis.py, lines 1–60 · score 0.55 · Mann Whitney, feeding rate, ANOVA, IBIs, feeding bouts, interaction
  17. [17] § Materials and methods › Signal processing pipeline of the Crunchometer ↔ crunchometer_python/crunchometer/core/audio.py, lines 67–132 · score 0.54 · 500–950 Hz, frequency band, sounds, spectrogram, power, bin
  18. [18] § Materials and methods › Identification of Chow vs. HFD consumption ↔ MoussionEnergy.m, lines 1–59 · score 0.54 · MoussionEnergy, Motion energy, ROI, frames, video

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,783 lines · 76 KB · MIT · 4 matches

  1. #!/usr/bin/env python3
  2. """
  3. Crunchometer Group Analysis v5.0
  4. =================================
  5. Complete pipeline for group-level analysis of feeding behavior data
  6. generated by CrunchometerV2.
  7. Ported from Crunchometer_Analysis_v5.m (MATLAB, 1710 lines)
  8. Features:
  9. - Pre-scan experiment folder structure (groups + subjects)
  10. - Load ground truth intake data (table or matrix format)
  11. - Compute feeding rates, bout durations, IBIs, and meal clusters
  12. - Calculate 12 per-subject metrics
  13. - Automatic statistical testing (t-test / Mann-Whitney / ANOVA / Kruskal-Wallis)
  14. - Outlier detection (2-SD) and CV reporting
  15. - Generate 5 publication-ready matplotlib figures
  16. - Export 2 Excel files (Summary + DeepAnalysis, 9 sheets total)
  17. - Save analysis log for traceability
  18. Matrix_Data columns (0-indexed in Python):
  19. Col 0: Start time of feeding bout (seconds)
  20. Col 1: End time of feeding bout (seconds)
  21. Col 2: Duration of feeding bout (seconds)
  22. Col 3: Number of bins crossing detection threshold
  23. Col 4: Power (dB) of each bite/event
  24. Col 5: Classification code (0=Artifact, 1=Chow, 2=HFD, 3=Gnawing)
  25. Original Author: Benjamin Arroyo (2025)
  26. Enhanced by: Ranier Gutierrez and AI Assistant (2026)
  27. """
  28. import os
  29. import re
  30. import warnings
  31. from dataclasses import dataclass, field
  32. from datetime import datetime
  33. from typing import Callable, Dict, List, Optional, Tuple
  34. import matplotlib
  35. # Use QtAgg backend when available (for popup figures in PyQt GUI),
  36. # fall back to Agg (non-interactive) for headless / scripted usage.
  37. try:
  38. matplotlib.use('QtAgg')
  39. except Exception:
  40. matplotlib.use('Agg')
  41. import matplotlib.pyplot as plt
  42. import matplotlib.patches as mpatches
  43. import numpy as np
  44. import pandas as pd
  45. from scipy.io import loadmat
  46. from scipy.optimize import curve_fit
  47. from scipy import stats
  48. # =========================================================================
  49. # CONFIGURATION
  50. # =========================================================================
  51. @dataclass
  52. class GroupAnalysisConfig:
  53. """Configuration parameters for group analysis pipeline."""
  54. bin_size: int = 60 # Time bin resolution (seconds)
  55. ibi_window: int = 600 # IBI averaging window (seconds)
  56. exp_duration: int = 7200 # Total experiment duration (seconds)
  57. meal_gap: int = 300 # Max gap between bouts in same meal (seconds)
  58. alpha_level: float = 0.05 # Significance threshold for statistics
  59. col_code: int = 5 # Classification column index (0-based)
  60. outlier_sd: float = 2.0 # SD threshold for outlier detection
  61. @dataclass
  62. class GroupData:
  63. """Container for a single treatment group's data."""
  64. name: str
  65. treat_idx: int # Index for legacy matrix GT format
  66. n_subs: int
  67. color: np.ndarray
  68. subjects: List[str]
  69. DM: List[Optional[np.ndarray]] = field(default_factory=list)
  70. intake: Optional[np.ndarray] = None # Feeding rate (all food)
  71. intake_chow: Optional[np.ndarray] = None # Chow only
  72. intake_hfd: Optional[np.ndarray] = None # HFD only
  73. bout_durations: List[Optional[np.ndarray]] = field(default_factory=list)
  74. ibi_values: List[Optional[np.ndarray]] = field(default_factory=list)
  75. meals: List[Optional[np.ndarray]] = field(default_factory=list)
  76. @dataclass
  77. class AnalysisResults:
  78. """Container for all analysis results."""
  79. figures: List[plt.Figure] = field(default_factory=list)
  80. output_path: str = ""
  81. summary_excel: str = ""
  82. deep_excel: str = ""
  83. log_file: str = ""
  84. # =========================================================================
  85. # DEFAULT COLORS
  86. # =========================================================================
  87. # Default group colors matching MATLAB palette
  88. DEFAULT_COLORS = np.array([
  89. [0.00, 0.50, 0.00], # Green
  90. [0.49, 0.18, 0.56], # Purple
  91. [0.85, 0.33, 0.10], # Orange
  92. [0.00, 0.45, 0.74], # Blue
  93. [0.30, 0.75, 0.93], # Light blue
  94. [0.64, 0.08, 0.18], # Dark red
  95. [0.47, 0.67, 0.19], # Olive
  96. [0.87, 0.49, 0.00], # Amber
  97. ])
  98. # Food type colors (consistent across all plots)
  99. FOOD_COLORS = {
  100. 'chow': np.array([0.93, 0.69, 0.13]), # Gold
  101. 'hfd': np.array([1.00, 0.00, 0.00]), # Red
  102. 'gnawing': np.array([0.00, 0.00, 0.00]), # Black
  103. }
  104. # Metrics definitions
  105. METRICS_LABELS = [
  106. 'Intake_g', 'Total_Time_s', 'Num_Bouts', 'Bout_Size_s',
  107. 'Latency_s', 'Mean_IBI_s', 'Num_Meals', 'Meal_Size_s',
  108. 'Pref_Ratio', 'Satiety_Ratio', 'Chow_Time_s', 'HFD_Time_s'
  109. ]
  110. METRICS_DISPLAY = [
  111. 'Intake (g)', 'Total Time (s)', '# Bouts', 'Bout Size (s)',
  112. 'Latency (s)', 'Mean IBI (s)', '# Meals', 'Meal Size (s)',
  113. 'Pref. Ratio', 'Satiety Ratio', 'Chow Time (s)', 'HFD Time (s)'
  114. ]
  115. N_METRICS = len(METRICS_LABELS)
  116. # =========================================================================
  117. # MAIN ANALYZER CLASS
  118. # =========================================================================
  119. class GroupAnalyzer:
  120. """
  121. Main group analysis engine for Crunchometer data.
  122. Orchestrates the full pipeline: scan → load → process → stats → figures → export.
  123. Parameters
  124. ----------
  125. config : GroupAnalysisConfig
  126. Analysis configuration parameters.
  127. experiment_path : str
  128. Path to the experiment root folder.
  129. intake_path : str, optional
  130. Path to manual intake Excel/CSV file.
  131. """
  132. def __init__(self, config: GroupAnalysisConfig, experiment_path: str,
  133. intake_path: Optional[str] = None):
  134. self.cfg = config
  135. self.experiment_path = experiment_path
  136. self.intake_path = intake_path or ""
  137. # Derived values
  138. self.experiment_name = re.sub(r'\s+', '_',
  139. os.path.basename(experiment_path.rstrip(os.sep)))
  140. self.results_path = os.path.join(experiment_path, 'Results')
  141. self.n_bins = int(np.ceil(self.cfg.exp_duration / self.cfg.bin_size))
  142. # Data containers (populated during processing)
  143. self.groups: List[GroupData] = []
  144. self.treatment_names: List[str] = []
  145. self.num_treatments: int = 0
  146. self.col: np.ndarray = np.array([]) # Group color matrix
  147. self.c_ibi_all: Optional[np.ndarray] = None # IBI temporal matrix
  148. # Results containers
  149. self.all_metrics: List[np.ndarray] = []
  150. self.stats_results: List[dict] = []
  151. self.outlier_flags: List[np.ndarray] = []
  152. self.cv_table: Optional[np.ndarray] = None
  153. self.GT_table = None
  154. self.has_intake: bool = False
  155. self.gt_format: str = 'none'
  156. # Log entries for traceability
  157. self.log_entries: List[str] = []
  158. # -----------------------------------------------------------------
  159. # STEP 1: SCAN EXPERIMENT STRUCTURE
  160. # -----------------------------------------------------------------
  161. def scan_experiment(self) -> None:
  162. """
  163. Discover treatment groups and subjects from folder structure.
  164. Expects: experiment_path / GroupName / Subject.mat
  165. Excludes: '.', '..', 'Results', '__pycache__'
  166. """
  167. self._log(f"Crunchometer Analysis v5.0 - {datetime.now():%Y-%m-%d %H:%M:%S}")
  168. self._log(f"Experiment: {self.experiment_name}")
  169. self._log(f"Path: {self.experiment_path}")
  170. self._log(f"Config: BinSize={self.cfg.bin_size}, IBIWindow={self.cfg.ibi_window}, "
  171. f"ExpDuration={self.cfg.exp_duration}, MealGap={self.cfg.meal_gap}")
  172. # Find treatment subfolders
  173. items = sorted(os.listdir(self.experiment_path))
  174. sub_folders = [
  175. d for d in items
  176. if os.path.isdir(os.path.join(self.experiment_path, d))
  177. and d not in ('.', '..', 'Results', '__pycache__')
  178. ]
  179. self.treatment_names = sub_folders
  180. self.num_treatments = len(sub_folders)
  181. if self.num_treatments == 0:
  182. raise ValueError(f"No treatment group folders found in: {self.experiment_path}")
  183. # Pre-scan all groups to find subjects
  184. subject_names = []
  185. max_subs = 0
  186. for i, treatment in enumerate(self.treatment_names):
  187. treat_path = os.path.join(self.experiment_path, treatment)
  188. mat_files = sorted([f for f in os.listdir(treat_path) if f.endswith('.mat')])
  189. subject_names.append(mat_files)
  190. n_subs = len(mat_files)
  191. if n_subs > max_subs:
  192. max_subs = n_subs
  193. self._log(f" {treatment}: {n_subs} subjects")
  194. # Assign colors
  195. if self.num_treatments <= len(DEFAULT_COLORS):
  196. self.col = DEFAULT_COLORS[:self.num_treatments]
  197. else:
  198. # Generate evenly spaced colors from a colormap
  199. cmap = plt.cm.get_cmap('tab10', self.num_treatments)
  200. self.col = np.array([cmap(i)[:3] for i in range(self.num_treatments)])
  201. # Create Results output folder
  202. os.makedirs(self.results_path, exist_ok=True)
  203. # Initialize group data structures
  204. self.groups = []
  205. for i, treatment in enumerate(self.treatment_names):
  206. n_s = len(subject_names[i])
  207. grp = GroupData(
  208. name=treatment,
  209. treat_idx=i,
  210. n_subs=n_s,
  211. color=self.col[i],
  212. subjects=subject_names[i],
  213. DM=[None] * n_s,
  214. intake=np.zeros((n_s, self.n_bins)),
  215. intake_chow=np.zeros((n_s, self.n_bins)),
  216. intake_hfd=np.zeros((n_s, self.n_bins)),
  217. bout_durations=[None] * n_s,
  218. ibi_values=[None] * n_s,
  219. meals=[None] * n_s,
  220. )
  221. self.groups.append(grp)
  222. # Pre-allocate IBI temporal matrix
  223. self.c_ibi_all = np.full((max_subs, self.cfg.exp_duration, self.num_treatments), np.nan)
  224. self._log(f"Groups found: {self.num_treatments}")
  225. # -----------------------------------------------------------------
  226. # STEP 2: LOAD GROUND TRUTH
  227. # -----------------------------------------------------------------
  228. def load_ground_truth(self) -> None:
  229. """
  230. Load manual intake ground truth data from Excel/CSV.
  231. Supports two formats:
  232. - 'table': Excel with Subject / Treatment / Intake_g columns (preferred)
  233. - 'matrix': Legacy numeric matrix (positional matching)
  234. """
  235. self.GT_table = None
  236. self.has_intake = False
  237. self.gt_format = 'none'
  238. manual_path = self.intake_path
  239. # Auto-search if no path provided
  240. if not manual_path:
  241. keywords = ['intake', 'manual', 'ground', 'gt', 'consumo', 'weight']
  242. for f in os.listdir(self.experiment_path):
  243. fpath = os.path.join(self.experiment_path, f)
  244. if os.path.isdir(fpath):
  245. continue
  246. _, ext = os.path.splitext(f)
  247. if ext.lower() == '.mat':
  248. continue # Skip .mat files
  249. fname_lower = f.lower()
  250. if any(kw in fname_lower for kw in keywords):
  251. manual_path = fpath
  252. break
  253. if not manual_path or not os.path.isfile(manual_path):
  254. self._log("Ground truth: NOT LOADED")
  255. return
  256. # Load the file
  257. try:
  258. _, ext = os.path.splitext(manual_path)
  259. if ext.lower() == '.mat':
  260. # Verify it's NOT a Crunchometer data file
  261. mat_data = loadmat(manual_path)
  262. if 'Matrix_Data' in mat_data:
  263. warnings.warn(f"File {manual_path} contains Matrix_Data. Skipping as intake file.")
  264. return
  265. # Use first non-system variable
  266. for key in mat_data:
  267. if not key.startswith('__'):
  268. self.GT_table = mat_data[key]
  269. self.gt_format = 'matrix'
  270. break
  271. else:
  272. # Try reading as table (preferred format)
  273. try:
  274. df = pd.read_excel(manual_path) if ext.lower() in ('.xlsx', '.xls') \
  275. else pd.read_csv(manual_path)
  276. required_cols = {'Subject', 'Treatment', 'Intake_g'}
  277. if required_cols.issubset(set(df.columns)):
  278. self.GT_table = df
  279. self.gt_format = 'table'
  280. else:
  281. # Fall back to matrix format
  282. self.GT_table = df.values
  283. self.gt_format = 'matrix'
  284. except Exception:
  285. # Last resort: try reading as numeric matrix
  286. df = pd.read_excel(manual_path) if ext.lower() in ('.xlsx', '.xls') \
  287. else pd.read_csv(manual_path)
  288. self.GT_table = df.values
  289. self.gt_format = 'matrix'
  290. self.has_intake = True
  291. self._log(f"Ground truth loaded: {manual_path} (format: {self.gt_format})")
  292. except Exception as e:
  293. warnings.warn(f"Error reading intake file: {e}")
  294. # -----------------------------------------------------------------
  295. # STEP 3: PROCESS ALL DATA
  296. # -----------------------------------------------------------------
  297. def process_all(self) -> None:
  298. """
  299. Load .mat files for each group/subject and compute:
  300. - Feeding rate time series (all food, chow-only, HFD-only)
  301. - Bout durations
  302. - Inter-bout intervals (IBIs)
  303. - Meal clusters
  304. """
  305. for i, grp in enumerate(self.groups):
  306. treat_path = os.path.join(self.experiment_path, grp.name)
  307. for j in range(grp.n_subs):
  308. f_path = os.path.join(treat_path, grp.subjects[j])
  309. try:
  310. mat = loadmat(f_path)
  311. if 'Matrix_Data' not in mat:
  312. self._log(f"WARNING: {grp.name}/{grp.subjects[j]} - no Matrix_Data")
  313. continue
  314. mdata = mat['Matrix_Data'].astype(float)
  315. # Validate Matrix_Data
  316. mdata = self._validate_matrix_data(mdata, grp.subjects[j])
  317. if mdata is None or len(mdata) == 0:
  318. continue
  319. grp.DM[j] = mdata
  320. code_col = mdata[:, self.cfg.col_code]
  321. # --- A: Feeding rate (combined, chow-only, hfd-only) ---
  322. for food_type in range(3): # 0=all, 1=chow, 2=hfd
  323. if food_type == 0:
  324. valid_codes = [1, 2]
  325. elif food_type == 1:
  326. valid_codes = [1]
  327. else:
  328. valid_codes = [2]
  329. valid_rows = np.isin(code_col, valid_codes)
  330. valid_evs = mdata[valid_rows][:, [0, 2]] # [start, duration]
  331. if len(valid_evs) > 0:
  332. # Force minimum 1s duration
  333. valid_evs[valid_evs[:, 1] <= 0, 1] = 1
  334. xt = np.zeros(self.cfg.exp_duration, dtype=float)
  335. for dt in range(len(valid_evs)):
  336. s_idx = max(0, int(round(valid_evs[dt, 0])))
  337. e_idx = min(self.cfg.exp_duration,
  338. s_idx + int(round(valid_evs[dt, 1])))
  339. xt[s_idx:e_idx] = 1
  340. for b in range(self.n_bins):
  341. idx_start = self.cfg.bin_size * b
  342. idx_end = min((b + 1) * self.cfg.bin_size,
  343. self.cfg.exp_duration)
  344. binned_val = np.sum(xt[idx_start:idx_end])
  345. if food_type == 0:
  346. grp.intake[j, b] = binned_val
  347. elif food_type == 1:
  348. grp.intake_chow[j, b] = binned_val
  349. else:
  350. grp.intake_hfd[j, b] = binned_val
  351. # --- B: Bout durations (from column 2 = duration) ---
  352. feed_rows = np.isin(code_col, [1, 2])
  353. if np.any(feed_rows):
  354. grp.bout_durations[j] = mdata[feed_rows, 2]
  355. # --- C: Inter-Bout Intervals ---
  356. det_f = mdata[feed_rows][:, 0:2] # [start, end]
  357. if len(det_f) > 1:
  358. ibis = det_f[1:, 0] - det_f[:-1, 1]
  359. grp.ibi_values[j] = ibis
  360. # Store in temporal matrix for IBI time course
  361. mid_times = det_f[:-1, 1] + ibis / 2
  362. idx_t = np.clip(np.round(mid_times).astype(int),
  363. 0, self.cfg.exp_duration - 1)
  364. for k in range(len(idx_t)):
  365. self.c_ibi_all[j, idx_t[k], i] = ibis[k]
  366. # --- D: Meal clustering ---
  367. if np.any(feed_rows):
  368. grp.meals[j] = self._cluster_bouts_into_meals(
  369. det_f, self.cfg.meal_gap)
  370. except Exception as e:
  371. self._log(f"ERROR: {grp.name}/{grp.subjects[j]} - {e}")
  372. # -----------------------------------------------------------------
  373. # STEP 4: COMPUTE METRICS
  374. # -----------------------------------------------------------------
  375. def compute_metrics(self) -> None:
  376. """
  377. Calculate 12 per-subject metrics for each treatment group.
  378. Metrics: Intake_g, Total_Time_s, Num_Bouts, Bout_Size_s,
  379. Latency_s, Mean_IBI_s, Num_Meals, Meal_Size_s,
  380. Pref_Ratio, Satiety_Ratio, Chow_Time_s, HFD_Time_s
  381. """
  382. self.all_metrics = []
  383. for grp in self.groups:
  384. mvals = np.full((grp.n_subs, N_METRICS), np.nan)
  385. for s in range(grp.n_subs):
  386. mvals[s, :] = self._compute_subject_metrics(s, grp)
  387. self.all_metrics.append(mvals)
  388. # -----------------------------------------------------------------
  389. # STEP 5: STATISTICAL ANALYSIS
  390. # -----------------------------------------------------------------
  391. def run_statistics(self) -> None:
  392. """
  393. Run automatic statistical tests on all 12 metrics.
  394. Selects appropriate test based on number of groups,
  395. sample sizes, and normality assumptions:
  396. - 2 groups, normal: Two-sample t-test
  397. - 2 groups, non-normal: Mann-Whitney U
  398. - 3+ groups, normal: One-way ANOVA (+ Tukey post-hoc)
  399. - 3+ groups, non-normal: Kruskal-Wallis (+ Dunn post-hoc)
  400. """
  401. self.stats_results = []
  402. for m in range(N_METRICS):
  403. data_per_group = []
  404. for i in range(self.num_treatments):
  405. vals = self.all_metrics[i][:, m]
  406. data_per_group.append(vals[~np.isnan(vals)])
  407. result = self._run_auto_stats(data_per_group, METRICS_DISPLAY[m])
  408. self.stats_results.append(result)
  409. self._log(f" {METRICS_DISPLAY[m]}: {result['test_name']}, p={result['p_value']:.4f}")
  410. # -----------------------------------------------------------------
  411. # STEP 6: OUTLIER DETECTION
  412. # -----------------------------------------------------------------
  413. def detect_outliers(self) -> None:
  414. """
  415. Flag outliers (>2 SD from mean) and compute coefficient of variation.
  416. """
  417. self.outlier_flags = []
  418. self.cv_table = np.zeros((self.num_treatments, N_METRICS))
  419. for i in range(self.num_treatments):
  420. mvals = self.all_metrics[i]
  421. flags = np.zeros(mvals.shape, dtype=bool)
  422. for m in range(N_METRICS):
  423. vals = mvals[:, m]
  424. valid = vals[~np.isnan(vals)]
  425. if len(valid) >= 3:
  426. mu = np.mean(valid)
  427. sd = np.std(valid, ddof=1)
  428. flags[:, m] = np.abs(vals - mu) > self.cfg.outlier_sd * sd
  429. if mu != 0:
  430. self.cv_table[i, m] = (sd / abs(mu)) * 100
  431. n_out = int(np.sum(flags[:, m]))
  432. if n_out > 0:
  433. self._log(f"OUTLIER: {self.treatment_names[i]}/"
  434. f"{METRICS_DISPLAY[m]} - {n_out} subject(s)")
  435. self.outlier_flags.append(flags)
  436. # -----------------------------------------------------------------
  437. # STEP 7: FIGURE GENERATION
  438. # -----------------------------------------------------------------
  439. def generate_figures(self) -> List[plt.Figure]:
  440. """
  441. Generate 5 publication-ready matplotlib figures.
  442. Returns
  443. -------
  444. list of matplotlib.figure.Figure
  445. The 5 generated figures.
  446. """
  447. figs = []
  448. figs.append(self._fig1_ethograms_cumulative())
  449. figs.append(self._fig2_core_metrics())
  450. figs.append(self._fig3_food_type())
  451. figs.append(self._fig4_microstructure())
  452. figs.append(self._fig5_advanced())
  453. return figs
  454. # -----------------------------------------------------------------
  455. # STEP 8: EXCEL EXPORT
  456. # -----------------------------------------------------------------
  457. def export_excel(self) -> Tuple[str, str]:
  458. """
  459. Export results to 2 Excel files:
  460. - Results_Summary.xlsx (4 sheets: Individual, Group Stats, Tests, CV)
  461. - DeepAnalysis_Data.xlsx (5 sheets: Events, Cumulative, Rate, IBI, Meals)
  462. Returns
  463. -------
  464. tuple of (str, str)
  465. Paths to the two generated Excel files.
  466. """
  467. xls_summary = os.path.join(
  468. self.results_path, f"{self.experiment_name}_Results_Summary.xlsx")
  469. xls_deep = os.path.join(
  470. self.results_path, f"{self.experiment_name}_DeepAnalysis_Data.xlsx")
  471. self._export_summary_excel(xls_summary)
  472. self._export_deep_excel(xls_deep)
  473. return xls_summary, xls_deep
  474. # -----------------------------------------------------------------
  475. # STEP 9: SAVE LOG
  476. # -----------------------------------------------------------------
  477. def save_log(self) -> str:
  478. """Save analysis log to text file. Returns log file path."""
  479. log_file = os.path.join(
  480. self.results_path, f"{self.experiment_name}_AnalysisLog.txt")
  481. self._log(f"Analysis completed: {datetime.now():%Y-%m-%d %H:%M:%S}")
  482. with open(log_file, 'w') as f:
  483. for entry in self.log_entries:
  484. f.write(entry + '\n')
  485. return log_file
  486. # -----------------------------------------------------------------
  487. # FULL PIPELINE ORCHESTRATOR
  488. # -----------------------------------------------------------------
  489. def run_full_pipeline(self, progress_callback: Optional[Callable] = None) -> AnalysisResults:
  490. """
  491. Run the complete analysis pipeline.
  492. Parameters
  493. ----------
  494. progress_callback : callable, optional
  495. Function(current, total, message) for progress updates.
  496. Returns
  497. -------
  498. AnalysisResults
  499. Container with figures, file paths, etc.
  500. """
  501. results = AnalysisResults()
  502. def _progress(step, total, msg):
  503. if progress_callback:
  504. progress_callback(step, total, msg)
  505. total_steps = 9
  506. # Step 1: Scan experiment structure
  507. _progress(1, total_steps, "Scanning experiment structure...")
  508. self.scan_experiment()
  509. # Step 2: Load ground truth
  510. _progress(2, total_steps, "Loading ground truth data...")
  511. self.load_ground_truth()
  512. # Step 3: Process all data
  513. _progress(3, total_steps, "Processing feeding data...")
  514. self.process_all()
  515. # Step 4: Compute metrics
  516. _progress(4, total_steps, "Computing per-subject metrics...")
  517. self.compute_metrics()
  518. # Step 5: Statistics
  519. _progress(5, total_steps, "Running statistical tests...")
  520. self.run_statistics()
  521. # Step 6: Outlier detection
  522. _progress(6, total_steps, "Detecting outliers...")
  523. self.detect_outliers()
  524. # Step 7: Generate figures
  525. _progress(7, total_steps, "Generating figures...")
  526. results.figures = self.generate_figures()
  527. # Step 8: Export Excel
  528. _progress(8, total_steps, "Exporting Excel reports...")
  529. results.summary_excel, results.deep_excel = self.export_excel()
  530. # Step 9: Save log
  531. _progress(9, total_steps, "Saving analysis log...")
  532. results.log_file = self.save_log()
  533. results.output_path = self.results_path
  534. return results
  535. # =====================================================================
  536. # PRIVATE HELPER METHODS
  537. # =====================================================================
  538. def _log(self, message: str) -> None:
  539. """Append a message to the analysis log."""
  540. self.log_entries.append(message)
  541. def _validate_matrix_data(self, mdata: np.ndarray, filename: str) -> Optional[np.ndarray]:
  542. """
  543. Validate Matrix_Data integrity.
  544. Returns cleaned array or None if invalid.
  545. """
  546. if mdata.ndim != 2 or mdata.shape[1] <= self.cfg.col_code:
  547. self._log(f"WARNING: {filename} has fewer than {self.cfg.col_code + 1} columns")
  548. return None
  549. # Remove rows with negative times
  550. bad_rows = (mdata[:, 0] < 0) | (mdata[:, 1] < 0)
  551. if np.any(bad_rows):
  552. self._log(f"WARNING: {filename}: Removed {int(np.sum(bad_rows))} rows "
  553. "with negative timestamps")
  554. mdata = mdata[~bad_rows]
  555. # Remove rows where start > end
  556. if len(mdata) > 0:
  557. bad_order = mdata[:, 0] > mdata[:, 1]
  558. if np.any(bad_order):
  559. self._log(f"WARNING: {filename}: Removed {int(np.sum(bad_order))} rows "
  560. "where start > end")
  561. mdata = mdata[~bad_order]
  562. if len(mdata) == 0:
  563. self._log(f"WARNING: {filename}: No valid data after validation")
  564. return None
  565. # Recalculate duration from col1 - col0
  566. mdata[:, 2] = mdata[:, 1] - mdata[:, 0]
  567. # Remove rows with zero or negative duration
  568. bad_dur = mdata[:, 2] <= 0
  569. if np.any(bad_dur):
  570. self._log(f"WARNING: {filename}: Removed {int(np.sum(bad_dur))} rows "
  571. "with duration <= 0")
  572. mdata = mdata[~bad_dur]
  573. # Remove events that START after the experiment duration
  574. # (these are entirely outside the analysis window)
  575. if len(mdata) > 0:
  576. after_exp = mdata[:, 0] >= self.cfg.exp_duration
  577. if np.any(after_exp):
  578. self._log(f"WARNING: {filename}: Removed {int(np.sum(after_exp))} "
  579. f"events starting after experiment duration "
  580. f"({self.cfg.exp_duration}s)")
  581. mdata = mdata[~after_exp]
  582. # Clip events that straddle the experiment boundary
  583. # (start < exp_duration < end)
  584. if len(mdata) > 0:
  585. over_dur = mdata[:, 1] > self.cfg.exp_duration
  586. if np.any(over_dur):
  587. mdata[over_dur, 1] = self.cfg.exp_duration
  588. mdata[over_dur, 2] = mdata[over_dur, 1] - mdata[over_dur, 0]
  589. # Safety: remove any that became <= 0 after clipping
  590. bad_after_clip = mdata[:, 2] <= 0
  591. if np.any(bad_after_clip):
  592. mdata = mdata[~bad_after_clip]
  593. if len(mdata) == 0:
  594. self._log(f"WARNING: {filename}: No valid data after validation")
  595. return None
  596. return mdata
  597. def _compute_subject_metrics(self, s: int, group: GroupData) -> np.ndarray:
  598. """
  599. Calculate all 12 metrics for a single subject.
  600. Returns a row vector of length N_METRICS.
  601. """
  602. mvals = np.full(N_METRICS, np.nan)
  603. data = group.DM[s]
  604. if data is None or data.ndim != 2 or data.shape[1] <= self.cfg.col_code:
  605. return mvals
  606. code_col = data[:, self.cfg.col_code]
  607. feed_rows = np.isin(code_col, [1, 2])
  608. evs = data[feed_rows]
  609. # 1: Intake (g) from ground truth
  610. if self.has_intake:
  611. weights = self._get_gt_for_group(group, group.treat_idx)
  612. if s < len(weights):
  613. mvals[0] = weights[s]
  614. # 2: Total feeding time (s)
  615. mvals[1] = np.sum(group.intake[s, :])
  616. # 3: Number of bouts
  617. mvals[2] = len(evs)
  618. # 4: Mean bout size (s)
  619. if len(evs) > 0:
  620. mvals[3] = np.mean(evs[:, 2])
  621. # 5: Latency to first bout (s)
  622. if len(evs) > 0:
  623. mvals[4] = evs[0, 0]
  624. # 6: Mean IBI (s)
  625. ibi_vals = group.ibi_values[s]
  626. if ibi_vals is not None and len(ibi_vals) > 0:
  627. mvals[5] = np.mean(ibi_vals)
  628. # 7: Number of meals
  629. meals = group.meals[s]
  630. if meals is not None and len(meals) > 0:
  631. mvals[6] = len(meals)
  632. # 8: Mean meal size (s)
  633. if meals is not None and len(meals) > 0:
  634. mvals[7] = np.mean(meals[:, 2])
  635. # 9: Preference ratio (HFD / total)
  636. chow_time = np.sum(group.intake_chow[s, :])
  637. hfd_time = np.sum(group.intake_hfd[s, :])
  638. total_time = chow_time + hfd_time
  639. if total_time > 0:
  640. mvals[8] = hfd_time / total_time
  641. # 10: Satiety ratio (mean post-bout IBI / mean bout duration)
  642. if (ibi_vals is not None and len(ibi_vals) > 0 and
  643. len(evs) > 1):
  644. mean_ibi = np.mean(ibi_vals)
  645. mean_bout = np.mean(evs[:, 2])
  646. if mean_bout > 0:
  647. mvals[9] = mean_ibi / mean_bout
  648. # 11: Chow time (s)
  649. mvals[10] = np.sum(group.intake_chow[s, :])
  650. # 12: HFD time (s)
  651. mvals[11] = np.sum(group.intake_hfd[s, :])
  652. return mvals
  653. def _get_gt_for_group(self, group: GroupData, treat_idx: int) -> np.ndarray:
  654. """
  655. Extract ground truth intake values for a specific group.
  656. Supports both 'table' (name-matched) and 'matrix' (positional) formats.
  657. """
  658. n_s = group.n_subs
  659. weights = np.full(n_s, np.nan)
  660. if self.GT_table is None:
  661. return weights
  662. if self.gt_format == 'table':
  663. df = self.GT_table
  664. for s in range(n_s):
  665. sname = os.path.splitext(group.subjects[s])[0]
  666. mask = (df['Subject'].astype(str) == sname) & \
  667. (df['Treatment'].astype(str) == group.name)
  668. matches = df.loc[mask, 'Intake_g']
  669. if len(matches) > 0:
  670. weights[s] = matches.iloc[0]
  671. elif self.gt_format == 'matrix':
  672. gt = self.GT_table
  673. if treat_idx < gt.shape[1]:
  674. n_valid = min(n_s, gt.shape[0])
  675. weights[:n_valid] = gt[:n_valid, treat_idx]
  676. return weights
  677. @staticmethod
  678. def _cluster_bouts_into_meals(bout_intervals: np.ndarray,
  679. meal_gap: float) -> Optional[np.ndarray]:
  680. """
  681. Group feeding bouts into meals based on inter-bout gaps.
  682. Parameters
  683. ----------
  684. bout_intervals : ndarray, shape (N, 2)
  685. [start_time, end_time] for each bout.
  686. meal_gap : float
  687. Maximum gap (seconds) between bouts in the same meal.
  688. Returns
  689. -------
  690. ndarray or None
  691. Shape (M, 4): [meal_start, meal_end, meal_duration, n_bouts]
  692. """
  693. if bout_intervals is None or len(bout_intervals) == 0:
  694. return None
  695. # Sort by start time
  696. sorted_bouts = bout_intervals[bout_intervals[:, 0].argsort()]
  697. n = len(sorted_bouts)
  698. meals = []
  699. meal_start = sorted_bouts[0, 0]
  700. meal_end = sorted_bouts[0, 1]
  701. n_bouts_in_meal = 1
  702. for k in range(1, n):
  703. gap = sorted_bouts[k, 0] - meal_end
  704. if gap <= meal_gap:
  705. # Continue current meal
  706. meal_end = sorted_bouts[k, 1]
  707. n_bouts_in_meal += 1
  708. else:
  709. # Close current meal, start new one
  710. meals.append([meal_start, meal_end,
  711. meal_end - meal_start, n_bouts_in_meal])
  712. meal_start = sorted_bouts[k, 0]
  713. meal_end = sorted_bouts[k, 1]
  714. n_bouts_in_meal = 1
  715. # Close last meal
  716. meals.append([meal_start, meal_end,
  717. meal_end - meal_start, n_bouts_in_meal])
  718. return np.array(meals)
  719. def _run_auto_stats(self, data_per_group: List[np.ndarray],
  720. metric_name: str) -> dict:
  721. """
  722. Automatically select and run appropriate statistical test.
  723. Returns dict with: test_name, statistic, p_value, decision, note, posthoc
  724. """
  725. result = {
  726. 'test_name': 'None',
  727. 'statistic': np.nan,
  728. 'p_value': np.nan,
  729. 'decision': 'N/A',
  730. 'note': '',
  731. 'posthoc': []
  732. }
  733. # Filter out empty groups
  734. valid_groups = [g for g in data_per_group if len(g) >= 1]
  735. if len(valid_groups) < 2:
  736. result['test_name'] = 'Descriptive only'
  737. result['note'] = 'Fewer than 2 valid groups for comparison.'
  738. return result
  739. # Check minimum sample sizes
  740. min_n = min(len(g) for g in valid_groups)
  741. if min_n < 3:
  742. result['test_name'] = 'Descriptive only'
  743. result['note'] = f'Insufficient sample size (min n={min_n}). Need n>=3 per group.'
  744. return result
  745. # Check normality (Shapiro-Wilk as Python alternative to lillietest)
  746. is_normal = True
  747. for g in valid_groups:
  748. if len(g) >= 4:
  749. try:
  750. _, p_shapiro = stats.shapiro(g)
  751. if p_shapiro < 0.05:
  752. is_normal = False
  753. break
  754. except Exception:
  755. is_normal = False
  756. break
  757. # Check homogeneity of variance (Bartlett or Levene)
  758. homo_var = True
  759. if len(valid_groups) >= 2 and is_normal:
  760. try:
  761. _, p_bart = stats.bartlett(*valid_groups)
  762. if p_bart < 0.05:
  763. homo_var = False
  764. except Exception:
  765. homo_var = True
  766. # Select and run test
  767. n_active = len(valid_groups)
  768. if n_active == 2:
  769. g1, g2 = valid_groups[0], valid_groups[1]
  770. if is_normal and homo_var:
  771. stat_val, p_val = stats.ttest_ind(g1, g2)
  772. result['test_name'] = 'Two-sample t-test'
  773. result['statistic'] = float(stat_val)
  774. result['p_value'] = float(p_val)
  775. result['note'] = 'Normality: passed. Equal variance: passed.'
  776. else:
  777. stat_val, p_val = stats.mannwhitneyu(g1, g2, alternative='two-sided')
  778. result['test_name'] = 'Mann-Whitney U'
  779. result['statistic'] = float(stat_val)
  780. result['p_value'] = float(p_val)
  781. reasons = []
  782. if not is_normal:
  783. reasons.append('Normality failed')
  784. if not homo_var:
  785. reasons.append('Unequal variance')
  786. result['note'] = f"Non-parametric ({'; '.join(reasons)})."
  787. elif n_active > 2:
  788. if is_normal and homo_var:
  789. stat_val, p_val = stats.f_oneway(*valid_groups)
  790. result['test_name'] = 'One-way ANOVA'
  791. result['statistic'] = float(stat_val)
  792. result['p_value'] = float(p_val)
  793. result['note'] = 'Normality: passed. Equal variance: passed.'
  794. # Post-hoc Tukey if significant
  795. if p_val < 0.05:
  796. try:
  797. tukey = stats.tukey_hsd(*valid_groups)
  798. for ii in range(len(valid_groups)):
  799. for jj in range(ii + 1, len(valid_groups)):
  800. ph_p = tukey.pvalue[ii, jj]
  801. sig = '*' if ph_p < 0.05 else 'ns'
  802. result['posthoc'].append(
  803. f"{self.treatment_names[ii]} vs "
  804. f"{self.treatment_names[jj]}: p={ph_p:.4f} {sig}")
  805. except Exception:
  806. pass
  807. else:
  808. stat_val, p_val = stats.kruskal(*valid_groups)
  809. result['test_name'] = 'Kruskal-Wallis'
  810. result['statistic'] = float(stat_val)
  811. result['p_value'] = float(p_val)
  812. reasons = []
  813. if not is_normal:
  814. reasons.append('Normality failed')
  815. if not homo_var:
  816. reasons.append('Unequal variance')
  817. result['note'] = f"Non-parametric ({'; '.join(reasons)})."
  818. # Post-hoc Dunn (simplified pairwise Mann-Whitney with Bonferroni)
  819. if p_val < 0.05:
  820. n_comp = n_active * (n_active - 1) // 2
  821. for ii in range(len(valid_groups)):
  822. for jj in range(ii + 1, len(valid_groups)):
  823. try:
  824. _, ph_p = stats.mannwhitneyu(
  825. valid_groups[ii], valid_groups[jj],
  826. alternative='two-sided')
  827. # Bonferroni correction
  828. ph_p_adj = min(ph_p * n_comp, 1.0)
  829. sig = '*' if ph_p_adj < 0.05 else 'ns'
  830. result['posthoc'].append(
  831. f"{self.treatment_names[ii]} vs "
  832. f"{self.treatment_names[jj]}: p={ph_p_adj:.4f} {sig}")
  833. except Exception:
  834. pass
  835. # Decision
  836. if not np.isnan(result['p_value']):
  837. p = result['p_value']
  838. if p < 0.001:
  839. result['decision'] = 'Significant (p<0.001)'
  840. elif p < 0.01:
  841. result['decision'] = 'Significant (p<0.01)'
  842. elif p < 0.05:
  843. result['decision'] = 'Significant (p<0.05)'
  844. else:
  845. result['decision'] = 'Not significant'
  846. # Low N warning
  847. if min_n < 5:
  848. result['note'] += ' WARNING: Low sample size (n<5), interpret with caution.'
  849. return result
  850. # =====================================================================
  851. # FIGURE GENERATION METHODS
  852. # =====================================================================
  853. def _shaded_error_bar(self, ax, x, y, err, color, linewidth=1.5,
  854. alpha=0.15, marker=None, markersize=4, label=None):
  855. """
  856. Plot mean line with shaded SEM region.
  857. Returns the main line handle.
  858. """
  859. y = np.asarray(y).ravel()
  860. err = np.asarray(err).ravel()
  861. x = np.asarray(x).ravel()
  862. kwargs = dict(color=color, linewidth=linewidth, label=label)
  863. if marker:
  864. kwargs['marker'] = marker
  865. kwargs['markersize'] = markersize
  866. kwargs['markerfacecolor'] = color
  867. line, = ax.plot(x, y, **kwargs)
  868. ax.fill_between(x, y - err, y + err, color=color, alpha=alpha)
  869. return line
  870. def _add_significance_bracket(self, ax, x1, x2, p_val):
  871. """Draw a significance bracket between two bar positions."""
  872. yl = ax.get_ylim()
  873. y_top = yl[1] * 0.90
  874. y_bracket = y_top * 0.95
  875. ax.plot([x1, x1, x2, x2],
  876. [y_top * 0.88, y_bracket, y_bracket, y_top * 0.88],
  877. '-k', linewidth=1)
  878. if p_val < 0.001:
  879. stars = '***'
  880. elif p_val < 0.01:
  881. stars = '**'
  882. elif p_val < 0.05:
  883. stars = '*'
  884. else:
  885. stars = 'ns'
  886. ax.text((x1 + x2) / 2, y_bracket * 1.02, stars,
  887. ha='center', fontsize=10, fontweight='bold')
  888. def _ethogram_plot(self, ax, matrix_data, sub_idx, duration, code_col_idx):
  889. """
  890. Draw color-coded patches for each behavioral event.
  891. Code 1 (Chow) = Gold, Code 2 (HFD) = Red, Code 3 (Gnawing) = Black
  892. """
  893. y_center = -(sub_idx + 1) # Offset each subject downward
  894. beh_codes = [1, 2, 3]
  895. beh_colors = [
  896. (0.93, 0.69, 0.13), # Gold (Chow)
  897. (1.0, 0.0, 0.0), # Red (HFD)
  898. (0.0, 0.0, 0.0), # Black (Gnawing)
  899. ]
  900. if matrix_data.shape[1] > code_col_idx:
  901. codes = matrix_data[:, code_col_idx]
  902. for k, code in enumerate(beh_codes):
  903. rows = codes == code
  904. if not np.any(rows):
  905. continue
  906. intervals = matrix_data[rows][:, 0:2]
  907. for iv in intervals:
  908. rect = mpatches.Rectangle(
  909. (iv[0], y_center - 0.4), iv[1] - iv[0], 0.8,
  910. facecolor=beh_colors[k], edgecolor='none')
  911. ax.add_patch(rect)
  912. def _save_figure(self, fig, fig_label: str) -> None:
  913. """Save figure in PNG format to results folder."""
  914. base_name = f"{self.experiment_name}_{fig_label}"
  915. full_base = os.path.join(self.results_path, base_name)
  916. fig.savefig(f"{full_base}.png", dpi=300, bbox_inches='tight',
  917. facecolor='white')
  918. # ----- FIGURE 1: Ethograms + Cumulative Feeding -----
  919. def _fig1_ethograms_cumulative(self) -> plt.Figure:
  920. """Generate Figure 1: Ethograms and cumulative feeding per group."""
  921. # Compute global Y limit for cumulative plots
  922. global_max_y = 0
  923. for grp in self.groups:
  924. if grp.intake is not None and grp.intake.size > 0:
  925. cum_d = np.cumsum(grp.intake, axis=1)
  926. n_s = cum_d.shape[0]
  927. peak = np.max(np.mean(cum_d, axis=0) +
  928. np.std(cum_d, axis=0, ddof=0) / np.sqrt(max(n_s, 1)))
  929. if peak > global_max_y:
  930. global_max_y = peak
  931. if global_max_y == 0:
  932. global_max_y = 10
  933. global_ylim = (0, global_max_y * 1.15)
  934. fig, axes = plt.subplots(2, self.num_treatments,
  935. figsize=(5 * self.num_treatments, 6.5),
  936. squeeze=False)
  937. for i, grp in enumerate(self.groups):
  938. # Top row: ethograms
  939. ax_top = axes[0, i]
  940. ax_top.set_title(grp.name.replace('_', ' '), color=grp.color,
  941. fontweight='bold', fontsize=11)
  942. valid_subs = 0
  943. for sub in range(len(grp.DM)):
  944. if grp.DM[sub] is not None:
  945. self._ethogram_plot(ax_top, grp.DM[sub], sub,
  946. self.cfg.exp_duration, self.cfg.col_code)
  947. valid_subs = sub + 1
  948. ax_top.set_xlim(0, self.cfg.exp_duration)
  949. if valid_subs > 0:
  950. ax_top.set_ylim(-valid_subs - 1, 0)
  951. ax_top.set_yticks([])
  952. ax_top.set_xlabel('Time (s)')
  953. if i == 0:
  954. ax_top.set_ylabel('Subjects')
  955. ax_top.spines['top'].set_visible(False)
  956. ax_top.spines['right'].set_visible(False)
  957. # Bottom row: cumulative feeding
  958. ax_bot = axes[1, i]
  959. data_mat = grp.intake
  960. if data_mat is not None and data_mat.size > 0:
  961. cum_data = np.cumsum(data_mat, axis=1)
  962. mu = np.mean(cum_data, axis=0)
  963. sigma = np.std(cum_data, axis=0, ddof=0) / np.sqrt(max(cum_data.shape[0], 1))
  964. x_axis = np.arange(1, cum_data.shape[1] + 1) * self.cfg.bin_size
  965. x_hours = x_axis / 3600.0
  966. self._shaded_error_bar(ax_bot, x_hours, mu, sigma, grp.color)
  967. ax_bot.set_xlim(0, self.cfg.exp_duration / 3600.0)
  968. ax_bot.set_ylim(global_ylim)
  969. ax_bot.set_xlabel('Time (h)')
  970. if i == 0:
  971. ax_bot.set_ylabel('Cum. Feeding Time (s)')
  972. ax_bot.spines['top'].set_visible(False)
  973. ax_bot.spines['right'].set_visible(False)
  974. fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Ethograms & Cumulative Feeding",
  975. fontsize=13, fontweight='bold')
  976. fig.tight_layout(rect=[0, 0, 1, 0.95])
  977. self._save_figure(fig, 'Fig1_Ethogram')
  978. return fig
  979. # ----- FIGURE 2: Core Metrics -----
  980. def _fig2_core_metrics(self) -> plt.Figure:
  981. """Generate Figure 2: IBI time course, validation, feeding rate, bar charts."""
  982. fig = plt.figure(figsize=(14, 9))
  983. gs = fig.add_gridspec(3, 6, hspace=0.45, wspace=0.5)
  984. # --- 2A: IBI Time Course ---
  985. ax_ibi = fig.add_subplot(gs[0, 0:4])
  986. n_bins_ibi = int(self.cfg.exp_duration // self.cfg.ibi_window)
  987. handles_ibi = []
  988. for i, grp in enumerate(self.groups):
  989. raw_ibi = self.c_ibi_all[:, :, i]
  990. ibi_binned_mean = np.zeros(n_bins_ibi)
  991. ibi_binned_sem = np.zeros(n_bins_ibi)
  992. x_ibi = np.zeros(n_bins_ibi)
  993. for b in range(n_bins_ibi):
  994. idx_s = b * self.cfg.ibi_window
  995. idx_e = (b + 1) * self.cfg.ibi_window
  996. block = raw_ibi[:, idx_s:idx_e]
  997. x_ibi[b] = (idx_s + idx_e) / 2.0
  998. vals = block[~np.isnan(block)]
  999. if len(vals) > 0:
  1000. ibi_binned_mean[b] = np.mean(vals)
  1001. ibi_binned_sem[b] = np.std(vals, ddof=0) / np.sqrt(len(vals))
  1002. x_hours = x_ibi / 3600.0
  1003. h = ax_ibi.errorbar(x_hours, ibi_binned_mean, yerr=ibi_binned_sem,
  1004. fmt='-o', color=grp.color, markerfacecolor='w',
  1005. linewidth=1.2, markersize=5,
  1006. label=grp.name.replace('_', ' '))
  1007. handles_ibi.append(h)
  1008. ax_ibi.set_ylabel('IBI (s)')
  1009. ax_ibi.set_xlabel('Time (h)')
  1010. ax_ibi.set_title('Inter-Bout Interval', fontsize=10)
  1011. ax_ibi.set_xlim(0, self.cfg.exp_duration / 3600.0)
  1012. ax_ibi.legend(fontsize=7, frameon=False, loc='best')
  1013. ax_ibi.spines['top'].set_visible(False)
  1014. ax_ibi.spines['right'].set_visible(False)
  1015. # --- 2B: Validation (Regression) ---
  1016. ax_reg = fig.add_subplot(gs[0, 4:6])
  1017. if self.has_intake:
  1018. all_weights = []
  1019. all_times = []
  1020. all_colors_reg = []
  1021. for i, grp in enumerate(self.groups):
  1022. total_time = np.sum(grp.intake, axis=1)
  1023. weights = self._get_gt_for_group(grp, i)
  1024. n_valid = min(len(total_time), len(weights))
  1025. w = weights[:n_valid]
  1026. t = total_time[:n_valid]
  1027. keep = ~np.isnan(w) & ~np.isnan(t)
  1028. w = w[keep]
  1029. t = t[keep]
  1030. if len(w) > 0:
  1031. ax_reg.plot(w, t, 'o', color=grp.color,
  1032. markerfacecolor=grp.color, markersize=6,
  1033. label=grp.name.replace('_', ' '))
  1034. all_weights.extend(w)
  1035. all_times.extend(t)
  1036. all_weights = np.array(all_weights)
  1037. all_times = np.array(all_times)
  1038. valid_idx = ~np.isnan(all_weights) & ~np.isnan(all_times)
  1039. if np.any(valid_idx):
  1040. w_v = all_weights[valid_idx]
  1041. t_v = all_times[valid_idx]
  1042. # Linear regression
  1043. coeffs = np.polyfit(w_v, t_v, 1)
  1044. y_fit = np.polyval(coeffs, w_v)
  1045. ss_res = np.sum((t_v - y_fit) ** 2)
  1046. ss_tot = np.sum((t_v - np.mean(t_v)) ** 2)
  1047. r_sq = 1 - ss_res / ss_tot if ss_tot > 0 else 0
  1048. x_line = np.linspace(np.min(w_v), np.max(w_v), 100)
  1049. ax_reg.plot(x_line, np.polyval(coeffs, x_line), '-r', linewidth=1.5)
  1050. ax_reg.text(0.05, 0.95, f'R² = {r_sq:.3f}',
  1051. transform=ax_reg.transAxes, color='r',
  1052. fontsize=10, fontweight='bold', va='top')
  1053. ax_reg.set_xlabel('Manual Intake (g)')
  1054. ax_reg.set_ylabel('Feeding Time (s)')
  1055. ax_reg.set_title('Regression Validation', fontsize=10)
  1056. ax_reg.legend(fontsize=7, frameon=False, loc='best')
  1057. ax_reg.grid(True, alpha=0.3)
  1058. else:
  1059. ax_reg.text(0.5, 0.5, 'No Intake Data', ha='center', va='center',
  1060. fontsize=11, color='gray', transform=ax_reg.transAxes)
  1061. ax_reg.set_axis_off()
  1062. # --- 2B2: Bland-Altman ---
  1063. ax_ba = fig.add_subplot(gs[1, 4:6])
  1064. if self.has_intake and len(all_weights) > 0:
  1065. w_v = np.array(all_weights)
  1066. t_v = np.array(all_times)
  1067. valid = ~np.isnan(w_v) & ~np.isnan(t_v)
  1068. if np.sum(valid) > 1:
  1069. w_z = (w_v[valid] - np.mean(w_v[valid])) / (np.std(w_v[valid], ddof=1) + 1e-10)
  1070. t_z = (t_v[valid] - np.mean(t_v[valid])) / (np.std(t_v[valid], ddof=1) + 1e-10)
  1071. avg_ba = (w_z + t_z) / 2
  1072. diff_ba = t_z - w_z
  1073. mean_diff = np.mean(diff_ba)
  1074. sd_diff = np.std(diff_ba, ddof=1)
  1075. # Build color array per point
  1076. color_list = []
  1077. for i, grp in enumerate(self.groups):
  1078. total_time = np.sum(grp.intake, axis=1)
  1079. weights = self._get_gt_for_group(grp, i)
  1080. n_valid = min(len(total_time), len(weights))
  1081. w = weights[:n_valid]
  1082. t = total_time[:n_valid]
  1083. keep = ~np.isnan(w) & ~np.isnan(t)
  1084. color_list.extend([grp.color] * int(np.sum(keep)))
  1085. color_arr = np.array(color_list) if color_list else np.array([[0, 0, 0]])
  1086. ax_ba.scatter(avg_ba, diff_ba, c=color_arr[:len(avg_ba)],
  1087. s=50, edgecolors='k', linewidths=0.5)
  1088. ax_ba.axhline(mean_diff, color='b', linewidth=1.2)
  1089. ax_ba.axhline(mean_diff + 1.96 * sd_diff, color='r',
  1090. linestyle='--', linewidth=1)
  1091. ax_ba.axhline(mean_diff - 1.96 * sd_diff, color='r',
  1092. linestyle='--', linewidth=1)
  1093. ax_ba.set_xlabel('Mean (z-score)')
  1094. ax_ba.set_ylabel('Difference (z-score)')
  1095. ax_ba.set_title('Bland-Altman (normalized)', fontsize=10)
  1096. ax_ba.text(0.05, 0.95, f'Bias = {mean_diff:.3f}',
  1097. transform=ax_ba.transAxes, color='b', fontsize=8,
  1098. fontweight='bold', va='top')
  1099. ax_ba.grid(True, alpha=0.3)
  1100. else:
  1101. ax_ba.text(0.5, 0.5, 'No Intake Data', ha='center', va='center',
  1102. fontsize=11, color='gray', transform=ax_ba.transAxes)
  1103. ax_ba.set_axis_off()
  1104. # --- 2C: Feeding Rate Time Course ---
  1105. ax_tc = fig.add_subplot(gs[1, 0:4])
  1106. for i, grp in enumerate(self.groups):
  1107. raw = grp.intake
  1108. if raw is None or raw.size == 0:
  1109. continue
  1110. group_factor = 10
  1111. new_cols = raw.shape[1] // group_factor
  1112. if new_cols == 0:
  1113. continue
  1114. binned_vals = np.zeros((raw.shape[0], new_cols))
  1115. for k in range(new_cols):
  1116. binned_vals[:, k] = np.sum(
  1117. raw[:, k * group_factor:(k + 1) * group_factor], axis=1)
  1118. mu = np.nanmean(binned_vals, axis=0)
  1119. err = np.nanstd(binned_vals, axis=0, ddof=0) / np.sqrt(max(binned_vals.shape[0], 1))
  1120. x_mins = np.linspace(0, self.cfg.exp_duration / 60.0, len(mu))
  1121. self._shaded_error_bar(ax_tc, x_mins, mu, err, grp.color,
  1122. marker='o', markersize=4,
  1123. label=grp.name.replace('_', ' '))
  1124. ax_tc.set_xlabel('Time (min)')
  1125. ax_tc.set_ylabel('Feeding Time (s / 10 min)')
  1126. ax_tc.set_title('Feeding Rate Time Course', fontsize=10)
  1127. ax_tc.legend(fontsize=7, frameon=False, loc='best')
  1128. ax_tc.spines['top'].set_visible(False)
  1129. ax_tc.spines['right'].set_visible(False)
  1130. # --- 2D: Bar charts for core metrics (6 panels) ---
  1131. core_metrics_idx = [0, 1, 2, 3, 4, 5] # 0-based
  1132. for m_i in range(6):
  1133. ax = fig.add_subplot(gs[2, m_i])
  1134. m = core_metrics_idx[m_i]
  1135. for ii in range(self.num_treatments):
  1136. vals = self.all_metrics[ii][:, m]
  1137. vals_clean = vals[~np.isnan(vals)]
  1138. if len(vals_clean) == 0:
  1139. continue
  1140. ax.bar(ii + 1, np.mean(vals_clean), color=self.col[ii],
  1141. edgecolor='none', alpha=0.6)
  1142. ax.errorbar(ii + 1, np.mean(vals_clean),
  1143. yerr=np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)),
  1144. fmt='none', ecolor='k', linewidth=1)
  1145. # Scatter individual points
  1146. jitter = (np.random.rand(len(vals_clean)) - 0.5) * 0.3
  1147. ax.plot(ii + 1 + jitter, vals_clean, '.', color='0.3', markersize=6)
  1148. ax.set_title(METRICS_DISPLAY[m], fontsize=9)
  1149. ax.set_xticks(range(1, self.num_treatments + 1))
  1150. ax.set_xticklabels(
  1151. [n.replace('_', ' ') for n in self.treatment_names],
  1152. rotation=45, ha='right', fontsize=7)
  1153. ax.set_aspect('auto')
  1154. # Significance bracket for 2-group comparisons
  1155. if (len(self.stats_results) > m and
  1156. self.stats_results[m]['p_value'] < 0.05 and
  1157. self.num_treatments == 2):
  1158. self._add_significance_bracket(ax, 1, 2, self.stats_results[m]['p_value'])
  1159. fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Core Metrics",
  1160. fontsize=13, fontweight='bold')
  1161. fig.tight_layout(rect=[0, 0, 1, 0.95])
  1162. self._save_figure(fig, 'Fig2_Metrics')
  1163. return fig
  1164. # ----- FIGURE 3: Food Type Analysis -----
  1165. def _fig3_food_type(self) -> plt.Figure:
  1166. """Generate Figure 3: Chow vs HFD cumulative, preference ratio, bars."""
  1167. fig, axes = plt.subplots(1, 3, figsize=(12, 5))
  1168. # --- 3A: Chow vs HFD time course ---
  1169. ax = axes[0]
  1170. for i, grp in enumerate(self.groups):
  1171. # Chow cumulative
  1172. cum_c = np.cumsum(grp.intake_chow, axis=1)
  1173. mu_c = np.mean(cum_c, axis=0)
  1174. x_h = np.arange(1, cum_c.shape[1] + 1) * self.cfg.bin_size / 3600.0
  1175. ax.plot(x_h, mu_c, '-', color=grp.color, linewidth=1.5,
  1176. label=f"{grp.name.replace('_', ' ')} Chow")
  1177. # HFD cumulative
  1178. cum_h = np.cumsum(grp.intake_hfd, axis=1)
  1179. mu_h = np.mean(cum_h, axis=0)
  1180. ax.plot(x_h, mu_h, '--', color=grp.color, linewidth=1.5,
  1181. label=f"{grp.name.replace('_', ' ')} HFD")
  1182. ax.set_xlabel('Time (h)')
  1183. ax.set_ylabel('Cum. Feeding Time (s)')
  1184. ax.set_title('Cumulative: Chow vs HFD', fontsize=10)
  1185. ax.legend(fontsize=7, frameon=False, loc='best')
  1186. ax.spines['top'].set_visible(False)
  1187. ax.spines['right'].set_visible(False)
  1188. # --- 3B: Preference Ratio over time ---
  1189. ax = axes[1]
  1190. for i, grp in enumerate(self.groups):
  1191. cum_c = np.cumsum(grp.intake_chow, axis=1)
  1192. cum_h = np.cumsum(grp.intake_hfd, axis=1)
  1193. total = cum_c + cum_h
  1194. pref = cum_h / np.maximum(total, 1) # Avoid division by zero
  1195. mu_p = np.nanmean(pref, axis=0)
  1196. err_p = np.nanstd(pref, axis=0, ddof=0) / np.sqrt(max(pref.shape[0], 1))
  1197. x_h = np.arange(1, pref.shape[1] + 1) * self.cfg.bin_size / 3600.0
  1198. self._shaded_error_bar(ax, x_h, mu_p, err_p, grp.color,
  1199. label=grp.name.replace('_', ' '))
  1200. ax.axhline(0.5, color='k', linestyle='--', linewidth=0.8)
  1201. ax.set_xlabel('Time (h)')
  1202. ax.set_ylabel('HFD Preference Ratio')
  1203. ax.set_title('Preference Ratio (HFD / Total)', fontsize=10)
  1204. ax.set_ylim(0, 1)
  1205. ax.legend(fontsize=7, frameon=False, loc='best')
  1206. ax.spines['top'].set_visible(False)
  1207. ax.spines['right'].set_visible(False)
  1208. # --- 3C: Chow vs HFD bar chart ---
  1209. ax = axes[2]
  1210. bar_width = 0.35
  1211. for i, grp in enumerate(self.groups):
  1212. chow_total = np.sum(grp.intake_chow, axis=1)
  1213. hfd_total = np.sum(grp.intake_hfd, axis=1)
  1214. x_pos = i + 1
  1215. ax.bar(x_pos - bar_width / 2, np.mean(chow_total), bar_width,
  1216. color=FOOD_COLORS['chow'], edgecolor='none', alpha=0.7)
  1217. ax.bar(x_pos + bar_width / 2, np.mean(hfd_total), bar_width,
  1218. color=FOOD_COLORS['hfd'], edgecolor='none', alpha=0.7)
  1219. ax.errorbar(x_pos - bar_width / 2, np.mean(chow_total),
  1220. yerr=np.std(chow_total, ddof=0) / np.sqrt(max(len(chow_total), 1)),
  1221. fmt='none', ecolor='k', linewidth=1)
  1222. ax.errorbar(x_pos + bar_width / 2, np.mean(hfd_total),
  1223. yerr=np.std(hfd_total, ddof=0) / np.sqrt(max(len(hfd_total), 1)),
  1224. fmt='none', ecolor='k', linewidth=1)
  1225. ax.set_xticks(range(1, self.num_treatments + 1))
  1226. ax.set_xticklabels([n.replace('_', ' ') for n in self.treatment_names],
  1227. rotation=45, ha='right', fontsize=9)
  1228. ax.set_ylabel('Total Feeding Time (s)')
  1229. ax.set_title('Chow vs HFD by Group', fontsize=10)
  1230. ax.legend(['Chow', 'HFD'], fontsize=8, frameon=False, loc='best')
  1231. fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Food Type Analysis",
  1232. fontsize=13, fontweight='bold')
  1233. fig.tight_layout(rect=[0, 0, 1, 0.95])
  1234. self._save_figure(fig, 'Fig3_FoodType')
  1235. return fig
  1236. # ----- FIGURE 4: Microstructure -----
  1237. def _fig4_microstructure(self) -> plt.Figure:
  1238. """Generate Figure 4: Bout/IBI distributions, meal metrics, activity heatmap."""
  1239. fig = plt.figure(figsize=(14, 7))
  1240. gs = fig.add_gridspec(2, 3, hspace=0.4, wspace=0.4)
  1241. # --- 4A: Bout duration distribution (boxplot) ---
  1242. ax = fig.add_subplot(gs[0, 0])
  1243. all_bout_dur = []
  1244. group_label_bout = []
  1245. for i, grp in enumerate(self.groups):
  1246. for s in range(grp.n_subs):
  1247. bd = grp.bout_durations[s]
  1248. if bd is not None and len(bd) > 0:
  1249. all_bout_dur.extend(bd)
  1250. group_label_bout.extend([i + 1] * len(bd))
  1251. if all_bout_dur:
  1252. bp_data = [[] for _ in range(self.num_treatments)]
  1253. for val, lbl in zip(all_bout_dur, group_label_bout):
  1254. bp_data[lbl - 1].append(val)
  1255. bp = ax.boxplot(bp_data, patch_artist=True, sym='.',
  1256. labels=[n.replace('_', ' ') for n in self.treatment_names])
  1257. for patch, color in zip(bp['boxes'], self.col):
  1258. patch.set_facecolor((*color, 0.5))
  1259. ax.set_ylabel('Bout Duration (s)')
  1260. ax.set_title('Bout Duration Distribution', fontsize=10)
  1261. ax.tick_params(axis='x', rotation=45)
  1262. # --- 4B: IBI distribution ---
  1263. ax = fig.add_subplot(gs[0, 1])
  1264. all_ibi = []
  1265. group_label_ibi = []
  1266. for i, grp in enumerate(self.groups):
  1267. for s in range(grp.n_subs):
  1268. ib = grp.ibi_values[s]
  1269. if ib is not None and len(ib) > 0:
  1270. all_ibi.extend(ib)
  1271. group_label_ibi.extend([i + 1] * len(ib))
  1272. if all_ibi:
  1273. bp_data = [[] for _ in range(self.num_treatments)]
  1274. for val, lbl in zip(all_ibi, group_label_ibi):
  1275. bp_data[lbl - 1].append(val)
  1276. bp = ax.boxplot(bp_data, patch_artist=True, sym='.',
  1277. labels=[n.replace('_', ' ') for n in self.treatment_names])
  1278. for patch, color in zip(bp['boxes'], self.col):
  1279. patch.set_facecolor((*color, 0.5))
  1280. ax.set_ylabel('IBI (s)')
  1281. ax.set_title('IBI Distribution', fontsize=10)
  1282. ax.tick_params(axis='x', rotation=45)
  1283. # --- 4C: Meal metrics bars (Num_Meals and Meal_Size) ---
  1284. meal_metrics_idx = [6, 7] # 0-based
  1285. for m_i in range(2):
  1286. ax = fig.add_subplot(gs[0, 2]) if m_i == 0 else fig.add_subplot(gs[1, 0])
  1287. # Fix: use separate subplot for each
  1288. if m_i == 1:
  1289. ax = fig.add_subplot(gs[1, 0])
  1290. m = meal_metrics_idx[m_i]
  1291. for ii in range(self.num_treatments):
  1292. vals = self.all_metrics[ii][:, m]
  1293. vals_clean = vals[~np.isnan(vals)]
  1294. if len(vals_clean) == 0:
  1295. continue
  1296. ax.bar(ii + 1, np.mean(vals_clean), color=self.col[ii],
  1297. edgecolor='none', alpha=0.6)
  1298. ax.errorbar(ii + 1, np.mean(vals_clean),
  1299. yerr=np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)),
  1300. fmt='none', ecolor='k', linewidth=1)
  1301. jitter = (np.random.rand(len(vals_clean)) - 0.5) * 0.3
  1302. ax.plot(ii + 1 + jitter, vals_clean, '.', color='0.3', markersize=8)
  1303. ax.set_title(METRICS_DISPLAY[m], fontsize=9)
  1304. ax.set_xticks(range(1, self.num_treatments + 1))
  1305. ax.set_xticklabels(
  1306. [n.replace('_', ' ') for n in self.treatment_names],
  1307. rotation=45, ha='right', fontsize=8)
  1308. # --- 4D: Activity heatmap ---
  1309. ax = fig.add_subplot(gs[1, 1:3])
  1310. all_heatmap_data = []
  1311. heatmap_labels = []
  1312. for i, grp in enumerate(self.groups):
  1313. raw = grp.intake
  1314. for s in range(raw.shape[0]):
  1315. all_heatmap_data.append(raw[s, :])
  1316. sname = os.path.splitext(grp.subjects[s])[0]
  1317. heatmap_labels.append(
  1318. f"{grp.name.replace('_', '')}_{sname}")
  1319. if all_heatmap_data:
  1320. heatmap_arr = np.array(all_heatmap_data)
  1321. im = ax.imshow(heatmap_arr, aspect='auto', cmap='hot',
  1322. interpolation='nearest')
  1323. fig.colorbar(im, ax=ax, fraction=0.02, pad=0.04)
  1324. ax.set_xlabel(f'Time bins ({self.cfg.bin_size} s)')
  1325. ax.set_ylabel('Subject')
  1326. ax.set_title('Feeding Activity Heatmap', fontsize=10)
  1327. ax.set_yticks(range(len(heatmap_labels)))
  1328. ax.set_yticklabels(heatmap_labels, fontsize=6)
  1329. # Add group separators
  1330. cum_n = 0
  1331. for i in range(self.num_treatments - 1):
  1332. cum_n += self.groups[i].n_subs
  1333. ax.axhline(cum_n - 0.5, color='white', linewidth=2)
  1334. fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Microstructure Analysis",
  1335. fontsize=13, fontweight='bold')
  1336. fig.tight_layout(rect=[0, 0, 1, 0.95])
  1337. self._save_figure(fig, 'Fig4_Microstructure')
  1338. return fig
  1339. # ----- FIGURE 5: Advanced Analytics -----
  1340. def _fig5_advanced(self) -> plt.Figure:
  1341. """Generate Figure 5: Satiety ratio, bout duration vs time, curve fitting."""
  1342. fig, axes = plt.subplots(1, 3, figsize=(12, 5))
  1343. # --- 5A: Satiety Ratio bars ---
  1344. ax = axes[0]
  1345. m = 9 # Satiety_Ratio (0-based)
  1346. for i in range(self.num_treatments):
  1347. vals = self.all_metrics[i][:, m]
  1348. vals_clean = vals[~np.isnan(vals)]
  1349. if len(vals_clean) == 0:
  1350. continue
  1351. ax.bar(i + 1, np.mean(vals_clean), color=self.col[i],
  1352. edgecolor='none', alpha=0.6)
  1353. ax.errorbar(i + 1, np.mean(vals_clean),
  1354. yerr=np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)),
  1355. fmt='none', ecolor='k', linewidth=1)
  1356. jitter = (np.random.rand(len(vals_clean)) - 0.5) * 0.3
  1357. ax.plot(i + 1 + jitter, vals_clean, '.', color='0.3', markersize=8)
  1358. ax.set_title('Satiety Ratio', fontsize=10)
  1359. ax.set_ylabel('IBI_post / Bout Duration')
  1360. ax.set_xticks(range(1, self.num_treatments + 1))
  1361. ax.set_xticklabels([n.replace('_', ' ') for n in self.treatment_names],
  1362. rotation=45, ha='right', fontsize=9)
  1363. # --- 5B: Bout duration vs time (scatter) ---
  1364. ax = axes[1]
  1365. for i, grp in enumerate(self.groups):
  1366. all_starts = []
  1367. all_durs = []
  1368. for s in range(grp.n_subs):
  1369. dm = grp.DM[s]
  1370. if dm is None:
  1371. continue
  1372. feed = dm[np.isin(dm[:, self.cfg.col_code], [1, 2])]
  1373. if len(feed) > 0:
  1374. all_starts.extend(feed[:, 0])
  1375. all_durs.extend(feed[:, 2])
  1376. if all_starts:
  1377. ax.scatter(np.array(all_starts) / 3600.0, all_durs,
  1378. s=15, c=[grp.color], alpha=0.4,
  1379. label=grp.name.replace('_', ' '))
  1380. ax.set_xlabel('Time (h)')
  1381. ax.set_ylabel('Bout Duration (s)')
  1382. ax.set_title('Bout Duration Over Time', fontsize=10)
  1383. ax.set_xlim(0, self.cfg.exp_duration / 3600.0)
  1384. ax.legend(fontsize=7, frameon=False, loc='best')
  1385. ax.spines['top'].set_visible(False)
  1386. ax.spines['right'].set_visible(False)
  1387. # --- 5C: Cumulative curve fitting ---
  1388. ax = axes[2]
  1389. for i, grp in enumerate(self.groups):
  1390. raw = grp.intake
  1391. if raw is None or raw.size == 0:
  1392. continue
  1393. cum_mean = np.mean(np.cumsum(raw, axis=1), axis=0)
  1394. x_sec = np.arange(1, len(cum_mean) + 1) * self.cfg.bin_size
  1395. x_h = x_sec / 3600.0
  1396. ax.plot(x_h, cum_mean, 'o', color=grp.color, markersize=3,
  1397. label=grp.name.replace('_', ' '))
  1398. # Fit exponential saturation: y = a * (1 - exp(-b * x))
  1399. try:
  1400. def sat_func(x, a, b):
  1401. return a * (1 - np.exp(-b * x))
  1402. popt, _ = curve_fit(sat_func, x_sec, cum_mean,
  1403. p0=[max(cum_mean) * 1.2, 1 / 1800.0],
  1404. bounds=([0, 0], [np.inf, np.inf]),
  1405. maxfev=5000)
  1406. x_fine = np.linspace(0, self.cfg.exp_duration, 200)
  1407. ax.plot(x_fine / 3600.0, sat_func(x_fine, *popt), '-',
  1408. color=grp.color, linewidth=1.5)
  1409. except Exception:
  1410. pass
  1411. ax.set_xlabel('Time (h)')
  1412. ax.set_ylabel('Cum. Feeding Time (s)')
  1413. ax.set_title('Cumulative Curve Fit', fontsize=10)
  1414. ax.legend(fontsize=7, frameon=False, loc='best')
  1415. ax.spines['top'].set_visible(False)
  1416. ax.spines['right'].set_visible(False)
  1417. fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Advanced Analytics",
  1418. fontsize=13, fontweight='bold')
  1419. fig.tight_layout(rect=[0, 0, 1, 0.95])
  1420. self._save_figure(fig, 'Fig5_Advanced')
  1421. return fig
  1422. # =====================================================================
  1423. # EXCEL EXPORT METHODS
  1424. # =====================================================================
  1425. def _export_summary_excel(self, xls_path: str) -> None:
  1426. """Export Results Summary Excel (4 sheets)."""
  1427. with pd.ExcelWriter(xls_path, engine='openpyxl') as writer:
  1428. # --- Sheet 1: Individual Data ---
  1429. rows = []
  1430. for i, grp in enumerate(self.groups):
  1431. for s in range(grp.n_subs):
  1432. sname = os.path.splitext(grp.subjects[s])[0]
  1433. row = {'Subject': sname, 'Treatment': grp.name}
  1434. for m in range(N_METRICS):
  1435. row[METRICS_LABELS[m]] = self.all_metrics[i][s, m]
  1436. rows.append(row)
  1437. pd.DataFrame(rows).to_excel(writer, sheet_name='Individual_Data', index=False)
  1438. # --- Sheet 2: Group Stats ---
  1439. rows = []
  1440. for i in range(self.num_treatments):
  1441. row = {'Treatment': self.treatment_names[i]}
  1442. for m in range(N_METRICS):
  1443. vals = self.all_metrics[i][:, m]
  1444. vals_clean = vals[~np.isnan(vals)]
  1445. if len(vals_clean) > 0:
  1446. row[f'{METRICS_LABELS[m]}_Mean'] = np.mean(vals_clean)
  1447. row[f'{METRICS_LABELS[m]}_SEM'] = (
  1448. np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)))
  1449. else:
  1450. row[f'{METRICS_LABELS[m]}_Mean'] = np.nan
  1451. row[f'{METRICS_LABELS[m]}_SEM'] = np.nan
  1452. rows.append(row)
  1453. pd.DataFrame(rows).to_excel(writer, sheet_name='Group_Stats', index=False)
  1454. # --- Sheet 3: Statistical Tests ---
  1455. rows = []
  1456. for m in range(N_METRICS):
  1457. sr = self.stats_results[m]
  1458. rows.append({
  1459. 'Metric': METRICS_DISPLAY[m],
  1460. 'Test': sr['test_name'],
  1461. 'Statistic': sr['statistic'],
  1462. 'P_Value': sr['p_value'],
  1463. 'Decision': sr['decision'],
  1464. 'Note': sr['note'],
  1465. })
  1466. pd.DataFrame(rows).to_excel(writer, sheet_name='Statistical_Tests', index=False)
  1467. # --- Sheet 4: CV Table ---
  1468. rows = []
  1469. for i in range(self.num_treatments):
  1470. row = {'Treatment': self.treatment_names[i]}
  1471. for m in range(N_METRICS):
  1472. row[f'{METRICS_LABELS[m]}_CV_pct'] = self.cv_table[i, m]
  1473. rows.append(row)
  1474. pd.DataFrame(rows).to_excel(writer, sheet_name='Variability_CV', index=False)
  1475. def _export_deep_excel(self, xls_path: str) -> None:
  1476. """Export Deep Analysis Data Excel (5 sheets)."""
  1477. with pd.ExcelWriter(xls_path, engine='openpyxl') as writer:
  1478. # --- Sheet 1: Raw Events ---
  1479. rows = []
  1480. for i, grp in enumerate(self.groups):
  1481. for s in range(grp.n_subs):
  1482. data = grp.DM[s]
  1483. if data is None or data.shape[1] <= self.cfg.col_code:
  1484. continue
  1485. codes = data[:, self.cfg.col_code]
  1486. valid = np.isin(codes, [1, 2, 3])
  1487. if not np.any(valid):
  1488. continue
  1489. evs = data[valid]
  1490. sname = os.path.splitext(grp.subjects[s])[0]
  1491. for e in range(len(evs)):
  1492. rows.append({
  1493. 'Subject': sname,
  1494. 'Treatment': grp.name,
  1495. 'Start_s': evs[e, 0],
  1496. 'End_s': evs[e, 1],
  1497. 'Duration_s': evs[e, 2],
  1498. 'Power_dB': evs[e, 4],
  1499. 'Type_Code': int(evs[e, self.cfg.col_code]),
  1500. })
  1501. pd.DataFrame(rows).to_excel(writer, sheet_name='Events_Raw', index=False)
  1502. # --- Sheet 2: Cumulative Time Course ---
  1503. timeline_min = np.arange(1, self.n_bins + 1) * self.cfg.bin_size / 60.0
  1504. cum_dict = {'Time_Min': timeline_min}
  1505. for i, grp in enumerate(self.groups):
  1506. cum_d = np.cumsum(grp.intake, axis=1)
  1507. for s in range(cum_d.shape[0]):
  1508. sname = os.path.splitext(grp.subjects[s])[0]
  1509. col_name = f"{re.sub('[^a-zA-Z0-9]', '', grp.name)}_{sname}"
  1510. cum_dict[col_name] = cum_d[s, :]
  1511. pd.DataFrame(cum_dict).to_excel(
  1512. writer, sheet_name='TimeCourse_Cumulative', index=False)
  1513. # --- Sheet 3: Feeding Rate ---
  1514. rate_dict = {'Time_Min': timeline_min}
  1515. for i, grp in enumerate(self.groups):
  1516. for s in range(grp.intake.shape[0]):
  1517. sname = os.path.splitext(grp.subjects[s])[0]
  1518. col_name = f"{re.sub('[^a-zA-Z0-9]', '', grp.name)}_{sname}"
  1519. rate_dict[col_name] = grp.intake[s, :]
  1520. pd.DataFrame(rate_dict).to_excel(
  1521. writer, sheet_name='TimeCourse_FeedingRate', index=False)
  1522. # --- Sheet 4: IBI Time Course ---
  1523. n_bins_ibi = int(self.cfg.exp_duration // self.cfg.ibi_window)
  1524. time_ibi = (np.arange(n_bins_ibi) * self.cfg.ibi_window +
  1525. self.cfg.ibi_window / 2.0) / 60.0
  1526. ibi_dict = {'Time_Min': time_ibi}
  1527. for i, grp in enumerate(self.groups):
  1528. raw_ibi = self.c_ibi_all[:, :, i]
  1529. for s in range(grp.n_subs):
  1530. subj_ibi_trace = np.full(n_bins_ibi, np.nan)
  1531. for b in range(n_bins_ibi):
  1532. idx_s = b * self.cfg.ibi_window
  1533. idx_e = (b + 1) * self.cfg.ibi_window
  1534. vals = raw_ibi[s, idx_s:idx_e]
  1535. vals = vals[~np.isnan(vals)]
  1536. if len(vals) > 0:
  1537. subj_ibi_trace[b] = np.mean(vals)
  1538. sname = os.path.splitext(grp.subjects[s])[0]
  1539. col_name = f"{re.sub('[^a-zA-Z0-9]', '', grp.name)}_{sname}"
  1540. ibi_dict[col_name] = subj_ibi_trace
  1541. pd.DataFrame(ibi_dict).to_excel(
  1542. writer, sheet_name='TimeCourse_IBI', index=False)
  1543. # --- Sheet 5: Meal Data ---
  1544. rows = []
  1545. for i, grp in enumerate(self.groups):
  1546. for s in range(grp.n_subs):
  1547. meals = grp.meals[s]
  1548. if meals is None or len(meals) == 0:
  1549. continue
  1550. sname = os.path.splitext(grp.subjects[s])[0]
  1551. for meal_num in range(len(meals)):
  1552. rows.append({
  1553. 'Subject': sname,
  1554. 'Treatment': grp.name,
  1555. 'Meal_Num': meal_num + 1,
  1556. 'Meal_Start_s': meals[meal_num, 0],
  1557. 'Meal_End_s': meals[meal_num, 1],
  1558. 'Meal_Duration_s': meals[meal_num, 2],
  1559. 'Num_Bouts_In_Meal': int(meals[meal_num, 3]),
  1560. })
  1561. pd.DataFrame(rows).to_excel(writer, sheet_name='Meal_Clusters', index=False)

group_analysis.py at commit 63b05d6, under MIT · at the source

Overview

Authors: Elvi Gil Lievana1,2, Benjamin Arroyo1,2, Jesús Pérez-Ortega1,2,3, Axel Lopez1,2, Luis Rodriguez-Blanco1,2, Xarenny Diaz1,2, Gustavo Hernandez1,2, Alam Coss1,3, Emily Alway4,5,6, Naama Reicher4,5, Enrique Hernández-Lemus7,8, Maya Kaelberer4,9, Diego V Bohórquez4,5,6,10,11,12,13, Ranier Gutierrez1,2
13 affiliations
  1. Laboratory Neurobiology of Appetite; Department of Pharmacology, CINVESTAV, Mexico City, Mexico
  2. Laboratory Neurobiology of Appetite; Center for Research on Aging (CIE), CINVESTAV, Mexico City, Mexico
  3. Facultad de Ingeniería, Universidad Nacional Autónoma de México, Mexico City, Mexico
  4. Department of Medicine, Duke University, Durham, United States
  5. Laboratory of Gut-Brain Neurobiology, Duke University, Durham, United States
  6. Department of Neurobiology, Duke University, Durham, United States
  7. Computational Genomics Division, National Institute of Genomic Medicine (INMEGEN), Mexico City, Mexico
  8. Center for Complexity Sciences, Universidad Nacional Autónoma de México, Mexico City, Mexico
  9. Department of Physiology, University of Arizona, Tucson, United States
  10. Department of Molecular Genetics and Microbiology, Duke University, Durham, United States
  11. Department of Pathology, Duke University, Durham, United States
  12. Department of Cell Biology, Duke University, Durham, United States
  13. Duke Institute for Brain Sciences, Duke University, Durham, United States
Journal: eLife, volume 14, article RP108663
Dates: published online 14 July 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.108663 · PMID 42444443 · PMCID PMC13368178 · OpenAlex W4415477351
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: mouse (organism), methods / tools (subfield)
Methods: Spectral & time-frequency, Statistics, Machine learning, Preprocessing, fMRI & imaging, Single-unit activity, calcium imaging, Smoothing, state filtering, decompositions
Keywords: Mouse
MeSH: Acoustics*, Feeding Behavior*, Animals, Eating, Male, Mice, Mice, Inbred C57BL, Neurons, Semaglutide (* major topic)
Topic: Infant Health and Development (Pharmacy, Health Professions), according to OpenAlex
Funding: Secretaría de Ciencia, Humanidades, Tecnología e Innovación (CBF-2026-1938, CF-2023-G-518); NIDDK NIH HHS (F30 DK136229, F32 DK139628, R01 DK131112, R03 DK114500, R01 DK132070, K01 DK131403); NIMH NIH HHS (DP2 MH122402); NCCIH NIH HHS (R21 AT010818)
Citations: cited by 2 papers (Europe PMC); 68 references in the paper

Abstract

Elucidating the neuronal circuits that govern appetite requires precise, high-resolution monitoring of the microstructure of solid food consumption, a need unmet by existing tools, which are either costly or lack the temporal resolution to align feeding events with neuronal activity. To overcome this, we developed the Crunchometer, a low-cost, open-source acoustic system that uses computational algorithms to generate high-resolution feeding ethograms from the sounds produced during solid food consumption. Validation across energy states (hunger/satiety) confirmed its sensitivity to changes in feeding microstructure, and the system reliably detected semaglutide-induced suppression of intake and reduced preference for a high-fat diet. Leveraging its seamless integration with in vivo recordings in freely behaving mice, we paired the Crunchometer with lateral hypothalamus (LH) electrophysiology to identify ‘meal-related’ neurons that track entire meals rather than individual bouts. Calcium imaging further revealed that distinct subsets of LH GABAergic and glutamatergic neurons were tuned to feeding only, to licking only, or to both behaviors. Thus, LH neuronal ensembles differentially encode the consumption of solid food versus liquid sucrose. These findings demonstrate that the Crunchometer is a robust, accessible platform for dissecting the neural correlates of feeding behavior at the resolution of a single bite.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 18 matches between paragraphs and lines of code.

OSF bmkdc

License: none: the authors keep all their rights
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 6 files
Software Heritage: not checked
Found in: the references
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
At the source:

RanierLabNeurobiologyAppetite2026/TheCrunchometerPy

License: MIT
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 63b05d6d0066b7277f2ef819fa61a238f406c206, 2 March 2026
Languages: Python (44), MATLAB (2), Shell (1)
Size: 87 files, 47 scripts
Software Heritage: not archived
Found in: the references
Holds: README, license file, environment (crunchometer_python/pyproject.toml, crunchometer_python/requirements.txt), continuous integration
Not found: CITATION.cff, tests, documentation
Tools: NumPy (32 files), SciPy (13 files), OpenCV (10 files), Matplotlib (7 files), pandas (7 files), h5py (1 file), scikit-learn (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
49 files

Zenodo 8422691

License: other-open
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: the references
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
11 files
At the source:

Zenodo 8423311

License: other-open
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: the references
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
  • 27 September 2026: the link answers (HTTP 200)
113 files
At the source:

perezortegaj/moussionenergy

License: GPL-3.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 51a2154f8e1b06dec210dad00f3eb200acaaaad4, 9 October 2023
Languages: MATLAB (10)
Size: 13 files, 10 scripts
Software Heritage: not archived
Found in: the Zenodo archive record
Holds: license file
Not found: README, CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
11 files

perezortegaj/xsembles2p

License: GPL-3.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 274cdb9bce3c6eec0db909c9a40dd6a2f30cdc01, 24 April 2024
Languages: MATLAB (112)
Size: 122 files, 112 scripts
Software Heritage: not archived
Found in: the Zenodo archive record
Holds: README, license file, CITATION.cff, tests
Not found: environment file, continuous integration, documentation
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
114 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 6 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 290 scripts, each with its path and the digest of its content;
  • 18 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability

Benchmark and Crunchometer software is available on OSF (Arroyo et al., 2025) at https://osf.io/bmkdc/. The OSF repository contains two folders under Files. The TheCrunchometerV2_Matlab folder includes both the classic scripts and an App GUI version, compatible with Windows, macOS (Intel and Apple Silicon), and Linux. This version requires a MATLAB license and FFmpeg, and is limited to videos up to 4 hours long. The TheCrunchometerV2_Python folder contains a standalone macOS Silicon executable (TheCrunchometer-2.0.0-macOS-arm64.dmg); this is the recommended version. It requires no MATLAB license or additional dependencies, supports 24-hour video files, and runs optimally on Apple Silicon hardware (e.g., Mac mini M4). To install, double-click the.dmg file and authorize the application via Apple menu → System Settings → Privacy & Security → Open Anyway. A standalone Windows executable is also available; it handles 24 hour videos but runs more slowly than the macOS build. Alternatively, a Python source version of the Crunchometer is available on GitHub (https://github.com/RanierLabNeurobiologyAppetite2026/TheCrunchometerPy, Arroyo et al., 2026). We recommend directly downloading the standalone software from GitHub using the following link: for MacOS Silicon: TheCrunchometer-2.0.0-macOS-arm64.dmg (https://github.com/RanierLabNeurobiologyAppetite2026/TheCrunchometerPy/releases/download/v2.0.0/TheCrunchometer-2.0.0-macOS-arm64.dmg) (to install, double-click the.dmg file and authorize the application via Apple menu → System Settings → Privacy & Security → Open Anyway); for Windows: Setup_TheCrunchometer-2.0.0-Windows-x64.exe (https://github.com/RanierLabNeurobiologyAppetite2026/TheCrunchometerPy/releases/download/v2.1.0/Setup_TheCrunchometer-2.0.0-Windows-x64.exe). An example folder, CrunchometerRunExample, is also provided, containing Audio_Data (.ogg) and Video_Data (.mkv) subfolders from a 10 minute recording for testing the Crunchometer software. A tutorial video on installing and running the Crunchometer is available at https://youtu.be/cubBnfOoGy8.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 14 authors, 1 keyword, 9 MeSH terms, 4 funders, 66 references.

Cite

This paper

Gil Lievana, E., Arroyo, B., Pérez-Ortega, J., Lopez, A., Rodriguez-Blanco, L., Diaz, X., Hernandez, G., Coss, A., Alway, E., Reicher, N., Hernández-Lemus, E., Kaelberer, M., Bohórquez, D. V., & Gutierrez, R. (2026). The Crunchometer, a low-cost, open-source acoustic analysis of feeding microstructure. eLife, 14, RP108663. https://doi.org/10.7554/elife.108663

BibTeX

@article{gillievana2026crunchometer,
author = {Gil Lievana, Elvi and Arroyo, Benjamin and Pérez-Ortega, Jesús and Lopez, Axel and Rodriguez-Blanco, Luis and Diaz, Xarenny and Hernandez, Gustavo and Coss, Alam and Alway, Emily and Reicher, Naama and Hernández-Lemus, Enrique and Kaelberer, Maya and Bohórquez, Diego V and Gutierrez, Ranier},
title = {{The Crunchometer, a low-cost, open-source acoustic analysis of feeding microstructure}},
journal = {eLife},
year = {2026},
month = jul,
volume = {14},
pages = {RP108663},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.108663},
url = {https://doi.org/10.7554/elife.108663},
pmid = {42444443},
pmcid = {PMC13368178}
}

RIS

TY - JOUR
AU - Gil Lievana, Elvi
AU - Arroyo, Benjamin
AU - Pérez-Ortega, Jesús
AU - Lopez, Axel
AU - Rodriguez-Blanco, Luis
AU - Diaz, Xarenny
AU - Hernandez, Gustavo
AU - Coss, Alam
AU - Alway, Emily
AU - Reicher, Naama
AU - Hernández-Lemus, Enrique
AU - Kaelberer, Maya
AU - Bohórquez, Diego V
AU - Gutierrez, Ranier
TI - The Crunchometer, a low-cost, open-source acoustic analysis of feeding microstructure
T2 - eLife
J2 - Elife
PY - 2026
DA - 2026/07/14
VL - 14
SP - RP108663
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.108663
UR - https://doi.org/10.7554/elife.108663
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.108663",
"type": "article-journal",
"title": "The Crunchometer, a low-cost, open-source acoustic analysis of feeding microstructure",
"container-title": "eLife",
"author": [
{
"family": "Gil Lievana",
"given": "Elvi"
},
{
"family": "Arroyo",
"given": "Benjamin"
},
{
"family": "Pérez-Ortega",
"given": "Jesús"
},
{
"family": "Lopez",
"given": "Axel"
},
{
"family": "Rodriguez-Blanco",
"given": "Luis"
},
{
"family": "Diaz",
"given": "Xarenny"
},
{
"family": "Hernandez",
"given": "Gustavo"
},
{
"family": "Coss",
"given": "Alam"
},
{
"family": "Alway",
"given": "Emily"
},
{
"family": "Reicher",
"given": "Naama"
},
{
"family": "Hernández-Lemus",
"given": "Enrique"
},
{
"family": "Kaelberer",
"given": "Maya"
},
{
"family": "Bohórquez",
"given": "Diego V"
},
{
"family": "Gutierrez",
"given": "Ranier"
}
],
"container-title-short": "Elife",
"volume": "14",
"page": "RP108663",
"DOI": "10.7554/elife.108663",
"PMID": "42444443",
"PMCID": "PMC13368178",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.108663",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
14
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1038/s41467-026-76581-6 [code]
Thalamocortical bursts encode reward contingencies and drive associative learning.
Journal: Nature communications
In common: OpenCV, h5py, Signal Processing Toolbox, 7 other tools, mouse
[2] doi:10.1038/s41592-026-03154-2 [code]
Simultaneous single-cell calcium imaging of neuronal population activity and brain-wide BOLD fMRI.
Journal: Nature methods
In common: OpenCV, h5py, Signal Processing Toolbox, 7 other tools, mouse
[3] doi:10.1038/s41593-026-02376-z [code]
A framework for comparative analysis of human and mouse cortical neuron dendrites in corresponding brain regions.
Journal: Nature neuroscience
In common: OpenCV, h5py, Signal Processing Toolbox, 7 other tools, mouse
[4] doi:10.1038/s41593-026-02232-0 [code]
Entorhinal cortex represents task-relevant remote locations independently of CA1.
Journal: Nature neuroscience
In common: OpenCV, h5py, Signal Processing Toolbox, 7 other tools, mouse
[5] doi:10.1016/j.isci.2026.117375 [code]
Motor priming is associated with widespread recruitment into neural ensembles and more rapid ensemble transitions.
Journal: iScience
In common: OpenCV, h5py, Signal Processing Toolbox, 7 other tools
[6] doi:10.1038/s41467-026-71458-0 [code]
Early differential impact of MeCP2 mutations on functional networks in Rett syndrome patient-derived human cortical organoids.
Journal: Nature communications
In common: OpenCV, h5py, Signal Processing Toolbox, 6 other tools
[7] doi:10.1162/imag.a.1299 [code]
A modular semantic-structural pipeline for visual decoding from primate spiking data via selective temporal integration.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: h5py, Signal Processing Toolbox, Image Processing Toolbox, 6 other tools, methods / tools
[8] doi:10.3389/fendo.2026.1828487 [code]
Castration-induced nigrostriatal deficits are linked to reduced TrkB and loss of mature spines in the dorsal striatum.
Journal: Frontiers in endocrinology
In common: OpenCV, h5py, Signal Processing Toolbox, 6 other tools, mouse
[9] doi:10.1038/s41593-026-02255-7 [code]
Neural circuits encode prior knowledge of temporal statistics.
Journal: Nature neuroscience
In common: Signal Processing Toolbox, Image Processing Toolbox, Statistics and Machine Learning Toolbox, 5 other tools, mouse, 1 reference
[10] doi:10.1016/j.neuron.2026.03.034 [code]
Dentate gyrus interneurons modulate winner-take-all network dynamics in freely behaving mice.
Journal: Neuron
In common: h5py, Signal Processing Toolbox, Image Processing Toolbox, 6 other tools, mouse

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.