The Crunchometer, a low-cost, open-source acoustic analysis of feeding microstructure.
The 18 matches
- [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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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] § 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
- #!/usr/bin/env python3
- """
- Crunchometer Group Analysis v5.0
- =================================
- Complete pipeline for group-level analysis of feeding behavior data
- generated by CrunchometerV2.
- Ported from Crunchometer_Analysis_v5.m (MATLAB, 1710 lines)
- Features:
- - Pre-scan experiment folder structure (groups + subjects)
- - Load ground truth intake data (table or matrix format)
- - Compute feeding rates, bout durations, IBIs, and meal clusters
- - Calculate 12 per-subject metrics
- - Automatic statistical testing (t-test / Mann-Whitney / ANOVA / Kruskal-Wallis)
- - Outlier detection (2-SD) and CV reporting
- - Generate 5 publication-ready matplotlib figures
- - Export 2 Excel files (Summary + DeepAnalysis, 9 sheets total)
- - Save analysis log for traceability
- Matrix_Data columns (0-indexed in Python):
- Col 0: Start time of feeding bout (seconds)
- Col 1: End time of feeding bout (seconds)
- Col 2: Duration of feeding bout (seconds)
- Col 3: Number of bins crossing detection threshold
- Col 4: Power (dB) of each bite/event
- Col 5: Classification code (0=Artifact, 1=Chow, 2=HFD, 3=Gnawing)
- Original Author: Benjamin Arroyo (2025)
- Enhanced by: Ranier Gutierrez and AI Assistant (2026)
- """
- import os
- import re
- import warnings
- from dataclasses import dataclass, field
- from datetime import datetime
- from typing import Callable, Dict, List, Optional, Tuple
- import matplotlib
- # Use QtAgg backend when available (for popup figures in PyQt GUI),
- # fall back to Agg (non-interactive) for headless / scripted usage.
- try:
- matplotlib.use('QtAgg')
- except Exception:
- matplotlib.use('Agg')
- import matplotlib.pyplot as plt
- import matplotlib.patches as mpatches
- import numpy as np
- import pandas as pd
- from scipy.io import loadmat
- from scipy.optimize import curve_fit
- from scipy import stats
- # =========================================================================
- # CONFIGURATION
- # =========================================================================
- @dataclass
- class GroupAnalysisConfig:
- """Configuration parameters for group analysis pipeline."""
- bin_size: int = 60 # Time bin resolution (seconds)
- ibi_window: int = 600 # IBI averaging window (seconds)
- exp_duration: int = 7200 # Total experiment duration (seconds)
- meal_gap: int = 300 # Max gap between bouts in same meal (seconds)
- alpha_level: float = 0.05 # Significance threshold for statistics
- col_code: int = 5 # Classification column index (0-based)
- outlier_sd: float = 2.0 # SD threshold for outlier detection
- @dataclass
- class GroupData:
- """Container for a single treatment group's data."""
- name: str
- treat_idx: int # Index for legacy matrix GT format
- n_subs: int
- color: np.ndarray
- subjects: List[str]
- DM: List[Optional[np.ndarray]] = field(default_factory=list)
- intake: Optional[np.ndarray] = None # Feeding rate (all food)
- intake_chow: Optional[np.ndarray] = None # Chow only
- intake_hfd: Optional[np.ndarray] = None # HFD only
- bout_durations: List[Optional[np.ndarray]] = field(default_factory=list)
- ibi_values: List[Optional[np.ndarray]] = field(default_factory=list)
- meals: List[Optional[np.ndarray]] = field(default_factory=list)
- @dataclass
- class AnalysisResults:
- """Container for all analysis results."""
- figures: List[plt.Figure] = field(default_factory=list)
- output_path: str = ""
- summary_excel: str = ""
- deep_excel: str = ""
- log_file: str = ""
- # =========================================================================
- # DEFAULT COLORS
- # =========================================================================
- # Default group colors matching MATLAB palette
- DEFAULT_COLORS = np.array([
- [0.00, 0.50, 0.00], # Green
- [0.49, 0.18, 0.56], # Purple
- [0.85, 0.33, 0.10], # Orange
- [0.00, 0.45, 0.74], # Blue
- [0.30, 0.75, 0.93], # Light blue
- [0.64, 0.08, 0.18], # Dark red
- [0.47, 0.67, 0.19], # Olive
- [0.87, 0.49, 0.00], # Amber
- ])
- # Food type colors (consistent across all plots)
- FOOD_COLORS = {
- 'chow': np.array([0.93, 0.69, 0.13]), # Gold
- 'hfd': np.array([1.00, 0.00, 0.00]), # Red
- 'gnawing': np.array([0.00, 0.00, 0.00]), # Black
- }
- # Metrics definitions
- METRICS_LABELS = [
- 'Intake_g', 'Total_Time_s', 'Num_Bouts', 'Bout_Size_s',
- 'Latency_s', 'Mean_IBI_s', 'Num_Meals', 'Meal_Size_s',
- 'Pref_Ratio', 'Satiety_Ratio', 'Chow_Time_s', 'HFD_Time_s'
- ]
- METRICS_DISPLAY = [
- 'Intake (g)', 'Total Time (s)', '# Bouts', 'Bout Size (s)',
- 'Latency (s)', 'Mean IBI (s)', '# Meals', 'Meal Size (s)',
- 'Pref. Ratio', 'Satiety Ratio', 'Chow Time (s)', 'HFD Time (s)'
- ]
- N_METRICS = len(METRICS_LABELS)
- # =========================================================================
- # MAIN ANALYZER CLASS
- # =========================================================================
- class GroupAnalyzer:
- """
- Main group analysis engine for Crunchometer data.
- Orchestrates the full pipeline: scan → load → process → stats → figures → export.
- Parameters
- ----------
- config : GroupAnalysisConfig
- Analysis configuration parameters.
- experiment_path : str
- Path to the experiment root folder.
- intake_path : str, optional
- Path to manual intake Excel/CSV file.
- """
- def __init__(self, config: GroupAnalysisConfig, experiment_path: str,
- intake_path: Optional[str] = None):
- self.cfg = config
- self.experiment_path = experiment_path
- self.intake_path = intake_path or ""
- # Derived values
- self.experiment_name = re.sub(r'\s+', '_',
- os.path.basename(experiment_path.rstrip(os.sep)))
- self.results_path = os.path.join(experiment_path, 'Results')
- self.n_bins = int(np.ceil(self.cfg.exp_duration / self.cfg.bin_size))
- # Data containers (populated during processing)
- self.groups: List[GroupData] = []
- self.treatment_names: List[str] = []
- self.num_treatments: int = 0
- self.col: np.ndarray = np.array([]) # Group color matrix
- self.c_ibi_all: Optional[np.ndarray] = None # IBI temporal matrix
- # Results containers
- self.all_metrics: List[np.ndarray] = []
- self.stats_results: List[dict] = []
- self.outlier_flags: List[np.ndarray] = []
- self.cv_table: Optional[np.ndarray] = None
- self.GT_table = None
- self.has_intake: bool = False
- self.gt_format: str = 'none'
- # Log entries for traceability
- self.log_entries: List[str] = []
- # -----------------------------------------------------------------
- # STEP 1: SCAN EXPERIMENT STRUCTURE
- # -----------------------------------------------------------------
- def scan_experiment(self) -> None:
- """
- Discover treatment groups and subjects from folder structure.
- Expects: experiment_path / GroupName / Subject.mat
- Excludes: '.', '..', 'Results', '__pycache__'
- """
- self._log(f"Crunchometer Analysis v5.0 - {datetime.now():%Y-%m-%d %H:%M:%S}")
- self._log(f"Experiment: {self.experiment_name}")
- self._log(f"Path: {self.experiment_path}")
- self._log(f"Config: BinSize={self.cfg.bin_size}, IBIWindow={self.cfg.ibi_window}, "
- f"ExpDuration={self.cfg.exp_duration}, MealGap={self.cfg.meal_gap}")
- # Find treatment subfolders
- items = sorted(os.listdir(self.experiment_path))
- sub_folders = [
- d for d in items
- if os.path.isdir(os.path.join(self.experiment_path, d))
- and d not in ('.', '..', 'Results', '__pycache__')
- ]
- self.treatment_names = sub_folders
- self.num_treatments = len(sub_folders)
- if self.num_treatments == 0:
- raise ValueError(f"No treatment group folders found in: {self.experiment_path}")
- # Pre-scan all groups to find subjects
- subject_names = []
- max_subs = 0
- for i, treatment in enumerate(self.treatment_names):
- treat_path = os.path.join(self.experiment_path, treatment)
- mat_files = sorted([f for f in os.listdir(treat_path) if f.endswith('.mat')])
- subject_names.append(mat_files)
- n_subs = len(mat_files)
- if n_subs > max_subs:
- max_subs = n_subs
- self._log(f" {treatment}: {n_subs} subjects")
- # Assign colors
- if self.num_treatments <= len(DEFAULT_COLORS):
- self.col = DEFAULT_COLORS[:self.num_treatments]
- else:
- # Generate evenly spaced colors from a colormap
- cmap = plt.cm.get_cmap('tab10', self.num_treatments)
- self.col = np.array([cmap(i)[:3] for i in range(self.num_treatments)])
- # Create Results output folder
- os.makedirs(self.results_path, exist_ok=True)
- # Initialize group data structures
- self.groups = []
- for i, treatment in enumerate(self.treatment_names):
- n_s = len(subject_names[i])
- grp = GroupData(
- name=treatment,
- treat_idx=i,
- n_subs=n_s,
- color=self.col[i],
- subjects=subject_names[i],
- DM=[None] * n_s,
- intake=np.zeros((n_s, self.n_bins)),
- intake_chow=np.zeros((n_s, self.n_bins)),
- intake_hfd=np.zeros((n_s, self.n_bins)),
- bout_durations=[None] * n_s,
- ibi_values=[None] * n_s,
- meals=[None] * n_s,
- )
- self.groups.append(grp)
- # Pre-allocate IBI temporal matrix
- self.c_ibi_all = np.full((max_subs, self.cfg.exp_duration, self.num_treatments), np.nan)
- self._log(f"Groups found: {self.num_treatments}")
- # -----------------------------------------------------------------
- # STEP 2: LOAD GROUND TRUTH
- # -----------------------------------------------------------------
- def load_ground_truth(self) -> None:
- """
- Load manual intake ground truth data from Excel/CSV.
- Supports two formats:
- - 'table': Excel with Subject / Treatment / Intake_g columns (preferred)
- - 'matrix': Legacy numeric matrix (positional matching)
- """
- self.GT_table = None
- self.has_intake = False
- self.gt_format = 'none'
- manual_path = self.intake_path
- # Auto-search if no path provided
- if not manual_path:
- keywords = ['intake', 'manual', 'ground', 'gt', 'consumo', 'weight']
- for f in os.listdir(self.experiment_path):
- fpath = os.path.join(self.experiment_path, f)
- if os.path.isdir(fpath):
- continue
- _, ext = os.path.splitext(f)
- if ext.lower() == '.mat':
- continue # Skip .mat files
- fname_lower = f.lower()
- if any(kw in fname_lower for kw in keywords):
- manual_path = fpath
- break
- if not manual_path or not os.path.isfile(manual_path):
- self._log("Ground truth: NOT LOADED")
- return
- # Load the file
- try:
- _, ext = os.path.splitext(manual_path)
- if ext.lower() == '.mat':
- # Verify it's NOT a Crunchometer data file
- mat_data = loadmat(manual_path)
- if 'Matrix_Data' in mat_data:
- warnings.warn(f"File {manual_path} contains Matrix_Data. Skipping as intake file.")
- return
- # Use first non-system variable
- for key in mat_data:
- if not key.startswith('__'):
- self.GT_table = mat_data[key]
- self.gt_format = 'matrix'
- break
- else:
- # Try reading as table (preferred format)
- try:
- df = pd.read_excel(manual_path) if ext.lower() in ('.xlsx', '.xls') \
- else pd.read_csv(manual_path)
- required_cols = {'Subject', 'Treatment', 'Intake_g'}
- if required_cols.issubset(set(df.columns)):
- self.GT_table = df
- self.gt_format = 'table'
- else:
- # Fall back to matrix format
- self.GT_table = df.values
- self.gt_format = 'matrix'
- except Exception:
- # Last resort: try reading as numeric matrix
- df = pd.read_excel(manual_path) if ext.lower() in ('.xlsx', '.xls') \
- else pd.read_csv(manual_path)
- self.GT_table = df.values
- self.gt_format = 'matrix'
- self.has_intake = True
- self._log(f"Ground truth loaded: {manual_path} (format: {self.gt_format})")
- except Exception as e:
- warnings.warn(f"Error reading intake file: {e}")
- # -----------------------------------------------------------------
- # STEP 3: PROCESS ALL DATA
- # -----------------------------------------------------------------
- def process_all(self) -> None:
- """
- Load .mat files for each group/subject and compute:
- - Feeding rate time series (all food, chow-only, HFD-only)
- - Bout durations
- - Inter-bout intervals (IBIs)
- - Meal clusters
- """
- for i, grp in enumerate(self.groups):
- treat_path = os.path.join(self.experiment_path, grp.name)
- for j in range(grp.n_subs):
- f_path = os.path.join(treat_path, grp.subjects[j])
- try:
- mat = loadmat(f_path)
- if 'Matrix_Data' not in mat:
- self._log(f"WARNING: {grp.name}/{grp.subjects[j]} - no Matrix_Data")
- continue
- mdata = mat['Matrix_Data'].astype(float)
- # Validate Matrix_Data
- mdata = self._validate_matrix_data(mdata, grp.subjects[j])
- if mdata is None or len(mdata) == 0:
- continue
- grp.DM[j] = mdata
- code_col = mdata[:, self.cfg.col_code]
- # --- A: Feeding rate (combined, chow-only, hfd-only) ---
- for food_type in range(3): # 0=all, 1=chow, 2=hfd
- if food_type == 0:
- valid_codes = [1, 2]
- elif food_type == 1:
- valid_codes = [1]
- else:
- valid_codes = [2]
- valid_rows = np.isin(code_col, valid_codes)
- valid_evs = mdata[valid_rows][:, [0, 2]] # [start, duration]
- if len(valid_evs) > 0:
- # Force minimum 1s duration
- valid_evs[valid_evs[:, 1] <= 0, 1] = 1
- xt = np.zeros(self.cfg.exp_duration, dtype=float)
- for dt in range(len(valid_evs)):
- s_idx = max(0, int(round(valid_evs[dt, 0])))
- e_idx = min(self.cfg.exp_duration,
- s_idx + int(round(valid_evs[dt, 1])))
- xt[s_idx:e_idx] = 1
- for b in range(self.n_bins):
- idx_start = self.cfg.bin_size * b
- idx_end = min((b + 1) * self.cfg.bin_size,
- self.cfg.exp_duration)
- binned_val = np.sum(xt[idx_start:idx_end])
- if food_type == 0:
- grp.intake[j, b] = binned_val
- elif food_type == 1:
- grp.intake_chow[j, b] = binned_val
- else:
- grp.intake_hfd[j, b] = binned_val
- # --- B: Bout durations (from column 2 = duration) ---
- feed_rows = np.isin(code_col, [1, 2])
- if np.any(feed_rows):
- grp.bout_durations[j] = mdata[feed_rows, 2]
- # --- C: Inter-Bout Intervals ---
- det_f = mdata[feed_rows][:, 0:2] # [start, end]
- if len(det_f) > 1:
- ibis = det_f[1:, 0] - det_f[:-1, 1]
- grp.ibi_values[j] = ibis
- # Store in temporal matrix for IBI time course
- mid_times = det_f[:-1, 1] + ibis / 2
- idx_t = np.clip(np.round(mid_times).astype(int),
- 0, self.cfg.exp_duration - 1)
- for k in range(len(idx_t)):
- self.c_ibi_all[j, idx_t[k], i] = ibis[k]
- # --- D: Meal clustering ---
- if np.any(feed_rows):
- grp.meals[j] = self._cluster_bouts_into_meals(
- det_f, self.cfg.meal_gap)
- except Exception as e:
- self._log(f"ERROR: {grp.name}/{grp.subjects[j]} - {e}")
- # -----------------------------------------------------------------
- # STEP 4: COMPUTE METRICS
- # -----------------------------------------------------------------
- def compute_metrics(self) -> None:
- """
- Calculate 12 per-subject metrics for each treatment group.
- Metrics: Intake_g, Total_Time_s, Num_Bouts, Bout_Size_s,
- Latency_s, Mean_IBI_s, Num_Meals, Meal_Size_s,
- Pref_Ratio, Satiety_Ratio, Chow_Time_s, HFD_Time_s
- """
- self.all_metrics = []
- for grp in self.groups:
- mvals = np.full((grp.n_subs, N_METRICS), np.nan)
- for s in range(grp.n_subs):
- mvals[s, :] = self._compute_subject_metrics(s, grp)
- self.all_metrics.append(mvals)
- # -----------------------------------------------------------------
- # STEP 5: STATISTICAL ANALYSIS
- # -----------------------------------------------------------------
- def run_statistics(self) -> None:
- """
- Run automatic statistical tests on all 12 metrics.
- Selects appropriate test based on number of groups,
- sample sizes, and normality assumptions:
- - 2 groups, normal: Two-sample t-test
- - 2 groups, non-normal: Mann-Whitney U
- - 3+ groups, normal: One-way ANOVA (+ Tukey post-hoc)
- - 3+ groups, non-normal: Kruskal-Wallis (+ Dunn post-hoc)
- """
- self.stats_results = []
- for m in range(N_METRICS):
- data_per_group = []
- for i in range(self.num_treatments):
- vals = self.all_metrics[i][:, m]
- data_per_group.append(vals[~np.isnan(vals)])
- result = self._run_auto_stats(data_per_group, METRICS_DISPLAY[m])
- self.stats_results.append(result)
- self._log(f" {METRICS_DISPLAY[m]}: {result['test_name']}, p={result['p_value']:.4f}")
- # -----------------------------------------------------------------
- # STEP 6: OUTLIER DETECTION
- # -----------------------------------------------------------------
- def detect_outliers(self) -> None:
- """
- Flag outliers (>2 SD from mean) and compute coefficient of variation.
- """
- self.outlier_flags = []
- self.cv_table = np.zeros((self.num_treatments, N_METRICS))
- for i in range(self.num_treatments):
- mvals = self.all_metrics[i]
- flags = np.zeros(mvals.shape, dtype=bool)
- for m in range(N_METRICS):
- vals = mvals[:, m]
- valid = vals[~np.isnan(vals)]
- if len(valid) >= 3:
- mu = np.mean(valid)
- sd = np.std(valid, ddof=1)
- flags[:, m] = np.abs(vals - mu) > self.cfg.outlier_sd * sd
- if mu != 0:
- self.cv_table[i, m] = (sd / abs(mu)) * 100
- n_out = int(np.sum(flags[:, m]))
- if n_out > 0:
- self._log(f"OUTLIER: {self.treatment_names[i]}/"
- f"{METRICS_DISPLAY[m]} - {n_out} subject(s)")
- self.outlier_flags.append(flags)
- # -----------------------------------------------------------------
- # STEP 7: FIGURE GENERATION
- # -----------------------------------------------------------------
- def generate_figures(self) -> List[plt.Figure]:
- """
- Generate 5 publication-ready matplotlib figures.
- Returns
- -------
- list of matplotlib.figure.Figure
- The 5 generated figures.
- """
- figs = []
- figs.append(self._fig1_ethograms_cumulative())
- figs.append(self._fig2_core_metrics())
- figs.append(self._fig3_food_type())
- figs.append(self._fig4_microstructure())
- figs.append(self._fig5_advanced())
- return figs
- # -----------------------------------------------------------------
- # STEP 8: EXCEL EXPORT
- # -----------------------------------------------------------------
- def export_excel(self) -> Tuple[str, str]:
- """
- Export results to 2 Excel files:
- - Results_Summary.xlsx (4 sheets: Individual, Group Stats, Tests, CV)
- - DeepAnalysis_Data.xlsx (5 sheets: Events, Cumulative, Rate, IBI, Meals)
- Returns
- -------
- tuple of (str, str)
- Paths to the two generated Excel files.
- """
- xls_summary = os.path.join(
- self.results_path, f"{self.experiment_name}_Results_Summary.xlsx")
- xls_deep = os.path.join(
- self.results_path, f"{self.experiment_name}_DeepAnalysis_Data.xlsx")
- self._export_summary_excel(xls_summary)
- self._export_deep_excel(xls_deep)
- return xls_summary, xls_deep
- # -----------------------------------------------------------------
- # STEP 9: SAVE LOG
- # -----------------------------------------------------------------
- def save_log(self) -> str:
- """Save analysis log to text file. Returns log file path."""
- log_file = os.path.join(
- self.results_path, f"{self.experiment_name}_AnalysisLog.txt")
- self._log(f"Analysis completed: {datetime.now():%Y-%m-%d %H:%M:%S}")
- with open(log_file, 'w') as f:
- for entry in self.log_entries:
- f.write(entry + '\n')
- return log_file
- # -----------------------------------------------------------------
- # FULL PIPELINE ORCHESTRATOR
- # -----------------------------------------------------------------
- def run_full_pipeline(self, progress_callback: Optional[Callable] = None) -> AnalysisResults:
- """
- Run the complete analysis pipeline.
- Parameters
- ----------
- progress_callback : callable, optional
- Function(current, total, message) for progress updates.
- Returns
- -------
- AnalysisResults
- Container with figures, file paths, etc.
- """
- results = AnalysisResults()
- def _progress(step, total, msg):
- if progress_callback:
- progress_callback(step, total, msg)
- total_steps = 9
- # Step 1: Scan experiment structure
- _progress(1, total_steps, "Scanning experiment structure...")
- self.scan_experiment()
- # Step 2: Load ground truth
- _progress(2, total_steps, "Loading ground truth data...")
- self.load_ground_truth()
- # Step 3: Process all data
- _progress(3, total_steps, "Processing feeding data...")
- self.process_all()
- # Step 4: Compute metrics
- _progress(4, total_steps, "Computing per-subject metrics...")
- self.compute_metrics()
- # Step 5: Statistics
- _progress(5, total_steps, "Running statistical tests...")
- self.run_statistics()
- # Step 6: Outlier detection
- _progress(6, total_steps, "Detecting outliers...")
- self.detect_outliers()
- # Step 7: Generate figures
- _progress(7, total_steps, "Generating figures...")
- results.figures = self.generate_figures()
- # Step 8: Export Excel
- _progress(8, total_steps, "Exporting Excel reports...")
- results.summary_excel, results.deep_excel = self.export_excel()
- # Step 9: Save log
- _progress(9, total_steps, "Saving analysis log...")
- results.log_file = self.save_log()
- results.output_path = self.results_path
- return results
- # =====================================================================
- # PRIVATE HELPER METHODS
- # =====================================================================
- def _log(self, message: str) -> None:
- """Append a message to the analysis log."""
- self.log_entries.append(message)
- def _validate_matrix_data(self, mdata: np.ndarray, filename: str) -> Optional[np.ndarray]:
- """
- Validate Matrix_Data integrity.
- Returns cleaned array or None if invalid.
- """
- if mdata.ndim != 2 or mdata.shape[1] <= self.cfg.col_code:
- self._log(f"WARNING: {filename} has fewer than {self.cfg.col_code + 1} columns")
- return None
- # Remove rows with negative times
- bad_rows = (mdata[:, 0] < 0) | (mdata[:, 1] < 0)
- if np.any(bad_rows):
- self._log(f"WARNING: {filename}: Removed {int(np.sum(bad_rows))} rows "
- "with negative timestamps")
- mdata = mdata[~bad_rows]
- # Remove rows where start > end
- if len(mdata) > 0:
- bad_order = mdata[:, 0] > mdata[:, 1]
- if np.any(bad_order):
- self._log(f"WARNING: {filename}: Removed {int(np.sum(bad_order))} rows "
- "where start > end")
- mdata = mdata[~bad_order]
- if len(mdata) == 0:
- self._log(f"WARNING: {filename}: No valid data after validation")
- return None
- # Recalculate duration from col1 - col0
- mdata[:, 2] = mdata[:, 1] - mdata[:, 0]
- # Remove rows with zero or negative duration
- bad_dur = mdata[:, 2] <= 0
- if np.any(bad_dur):
- self._log(f"WARNING: {filename}: Removed {int(np.sum(bad_dur))} rows "
- "with duration <= 0")
- mdata = mdata[~bad_dur]
- # Remove events that START after the experiment duration
- # (these are entirely outside the analysis window)
- if len(mdata) > 0:
- after_exp = mdata[:, 0] >= self.cfg.exp_duration
- if np.any(after_exp):
- self._log(f"WARNING: {filename}: Removed {int(np.sum(after_exp))} "
- f"events starting after experiment duration "
- f"({self.cfg.exp_duration}s)")
- mdata = mdata[~after_exp]
- # Clip events that straddle the experiment boundary
- # (start < exp_duration < end)
- if len(mdata) > 0:
- over_dur = mdata[:, 1] > self.cfg.exp_duration
- if np.any(over_dur):
- mdata[over_dur, 1] = self.cfg.exp_duration
- mdata[over_dur, 2] = mdata[over_dur, 1] - mdata[over_dur, 0]
- # Safety: remove any that became <= 0 after clipping
- bad_after_clip = mdata[:, 2] <= 0
- if np.any(bad_after_clip):
- mdata = mdata[~bad_after_clip]
- if len(mdata) == 0:
- self._log(f"WARNING: {filename}: No valid data after validation")
- return None
- return mdata
- def _compute_subject_metrics(self, s: int, group: GroupData) -> np.ndarray:
- """
- Calculate all 12 metrics for a single subject.
- Returns a row vector of length N_METRICS.
- """
- mvals = np.full(N_METRICS, np.nan)
- data = group.DM[s]
- if data is None or data.ndim != 2 or data.shape[1] <= self.cfg.col_code:
- return mvals
- code_col = data[:, self.cfg.col_code]
- feed_rows = np.isin(code_col, [1, 2])
- evs = data[feed_rows]
- # 1: Intake (g) from ground truth
- if self.has_intake:
- weights = self._get_gt_for_group(group, group.treat_idx)
- if s < len(weights):
- mvals[0] = weights[s]
- # 2: Total feeding time (s)
- mvals[1] = np.sum(group.intake[s, :])
- # 3: Number of bouts
- mvals[2] = len(evs)
- # 4: Mean bout size (s)
- if len(evs) > 0:
- mvals[3] = np.mean(evs[:, 2])
- # 5: Latency to first bout (s)
- if len(evs) > 0:
- mvals[4] = evs[0, 0]
- # 6: Mean IBI (s)
- ibi_vals = group.ibi_values[s]
- if ibi_vals is not None and len(ibi_vals) > 0:
- mvals[5] = np.mean(ibi_vals)
- # 7: Number of meals
- meals = group.meals[s]
- if meals is not None and len(meals) > 0:
- mvals[6] = len(meals)
- # 8: Mean meal size (s)
- if meals is not None and len(meals) > 0:
- mvals[7] = np.mean(meals[:, 2])
- # 9: Preference ratio (HFD / total)
- chow_time = np.sum(group.intake_chow[s, :])
- hfd_time = np.sum(group.intake_hfd[s, :])
- total_time = chow_time + hfd_time
- if total_time > 0:
- mvals[8] = hfd_time / total_time
- # 10: Satiety ratio (mean post-bout IBI / mean bout duration)
- if (ibi_vals is not None and len(ibi_vals) > 0 and
- len(evs) > 1):
- mean_ibi = np.mean(ibi_vals)
- mean_bout = np.mean(evs[:, 2])
- if mean_bout > 0:
- mvals[9] = mean_ibi / mean_bout
- # 11: Chow time (s)
- mvals[10] = np.sum(group.intake_chow[s, :])
- # 12: HFD time (s)
- mvals[11] = np.sum(group.intake_hfd[s, :])
- return mvals
- def _get_gt_for_group(self, group: GroupData, treat_idx: int) -> np.ndarray:
- """
- Extract ground truth intake values for a specific group.
- Supports both 'table' (name-matched) and 'matrix' (positional) formats.
- """
- n_s = group.n_subs
- weights = np.full(n_s, np.nan)
- if self.GT_table is None:
- return weights
- if self.gt_format == 'table':
- df = self.GT_table
- for s in range(n_s):
- sname = os.path.splitext(group.subjects[s])[0]
- mask = (df['Subject'].astype(str) == sname) & \
- (df['Treatment'].astype(str) == group.name)
- matches = df.loc[mask, 'Intake_g']
- if len(matches) > 0:
- weights[s] = matches.iloc[0]
- elif self.gt_format == 'matrix':
- gt = self.GT_table
- if treat_idx < gt.shape[1]:
- n_valid = min(n_s, gt.shape[0])
- weights[:n_valid] = gt[:n_valid, treat_idx]
- return weights
- @staticmethod
- def _cluster_bouts_into_meals(bout_intervals: np.ndarray,
- meal_gap: float) -> Optional[np.ndarray]:
- """
- Group feeding bouts into meals based on inter-bout gaps.
- Parameters
- ----------
- bout_intervals : ndarray, shape (N, 2)
- [start_time, end_time] for each bout.
- meal_gap : float
- Maximum gap (seconds) between bouts in the same meal.
- Returns
- -------
- ndarray or None
- Shape (M, 4): [meal_start, meal_end, meal_duration, n_bouts]
- """
- if bout_intervals is None or len(bout_intervals) == 0:
- return None
- # Sort by start time
- sorted_bouts = bout_intervals[bout_intervals[:, 0].argsort()]
- n = len(sorted_bouts)
- meals = []
- meal_start = sorted_bouts[0, 0]
- meal_end = sorted_bouts[0, 1]
- n_bouts_in_meal = 1
- for k in range(1, n):
- gap = sorted_bouts[k, 0] - meal_end
- if gap <= meal_gap:
- # Continue current meal
- meal_end = sorted_bouts[k, 1]
- n_bouts_in_meal += 1
- else:
- # Close current meal, start new one
- meals.append([meal_start, meal_end,
- meal_end - meal_start, n_bouts_in_meal])
- meal_start = sorted_bouts[k, 0]
- meal_end = sorted_bouts[k, 1]
- n_bouts_in_meal = 1
- # Close last meal
- meals.append([meal_start, meal_end,
- meal_end - meal_start, n_bouts_in_meal])
- return np.array(meals)
- def _run_auto_stats(self, data_per_group: List[np.ndarray],
- metric_name: str) -> dict:
- """
- Automatically select and run appropriate statistical test.
- Returns dict with: test_name, statistic, p_value, decision, note, posthoc
- """
- result = {
- 'test_name': 'None',
- 'statistic': np.nan,
- 'p_value': np.nan,
- 'decision': 'N/A',
- 'note': '',
- 'posthoc': []
- }
- # Filter out empty groups
- valid_groups = [g for g in data_per_group if len(g) >= 1]
- if len(valid_groups) < 2:
- result['test_name'] = 'Descriptive only'
- result['note'] = 'Fewer than 2 valid groups for comparison.'
- return result
- # Check minimum sample sizes
- min_n = min(len(g) for g in valid_groups)
- if min_n < 3:
- result['test_name'] = 'Descriptive only'
- result['note'] = f'Insufficient sample size (min n={min_n}). Need n>=3 per group.'
- return result
- # Check normality (Shapiro-Wilk as Python alternative to lillietest)
- is_normal = True
- for g in valid_groups:
- if len(g) >= 4:
- try:
- _, p_shapiro = stats.shapiro(g)
- if p_shapiro < 0.05:
- is_normal = False
- break
- except Exception:
- is_normal = False
- break
- # Check homogeneity of variance (Bartlett or Levene)
- homo_var = True
- if len(valid_groups) >= 2 and is_normal:
- try:
- _, p_bart = stats.bartlett(*valid_groups)
- if p_bart < 0.05:
- homo_var = False
- except Exception:
- homo_var = True
- # Select and run test
- n_active = len(valid_groups)
- if n_active == 2:
- g1, g2 = valid_groups[0], valid_groups[1]
- if is_normal and homo_var:
- stat_val, p_val = stats.ttest_ind(g1, g2)
- result['test_name'] = 'Two-sample t-test'
- result['statistic'] = float(stat_val)
- result['p_value'] = float(p_val)
- result['note'] = 'Normality: passed. Equal variance: passed.'
- else:
- stat_val, p_val = stats.mannwhitneyu(g1, g2, alternative='two-sided')
- result['test_name'] = 'Mann-Whitney U'
- result['statistic'] = float(stat_val)
- result['p_value'] = float(p_val)
- reasons = []
- if not is_normal:
- reasons.append('Normality failed')
- if not homo_var:
- reasons.append('Unequal variance')
- result['note'] = f"Non-parametric ({'; '.join(reasons)})."
- elif n_active > 2:
- if is_normal and homo_var:
- stat_val, p_val = stats.f_oneway(*valid_groups)
- result['test_name'] = 'One-way ANOVA'
- result['statistic'] = float(stat_val)
- result['p_value'] = float(p_val)
- result['note'] = 'Normality: passed. Equal variance: passed.'
- # Post-hoc Tukey if significant
- if p_val < 0.05:
- try:
- tukey = stats.tukey_hsd(*valid_groups)
- for ii in range(len(valid_groups)):
- for jj in range(ii + 1, len(valid_groups)):
- ph_p = tukey.pvalue[ii, jj]
- sig = '*' if ph_p < 0.05 else 'ns'
- result['posthoc'].append(
- f"{self.treatment_names[ii]} vs "
- f"{self.treatment_names[jj]}: p={ph_p:.4f} {sig}")
- except Exception:
- pass
- else:
- stat_val, p_val = stats.kruskal(*valid_groups)
- result['test_name'] = 'Kruskal-Wallis'
- result['statistic'] = float(stat_val)
- result['p_value'] = float(p_val)
- reasons = []
- if not is_normal:
- reasons.append('Normality failed')
- if not homo_var:
- reasons.append('Unequal variance')
- result['note'] = f"Non-parametric ({'; '.join(reasons)})."
- # Post-hoc Dunn (simplified pairwise Mann-Whitney with Bonferroni)
- if p_val < 0.05:
- n_comp = n_active * (n_active - 1) // 2
- for ii in range(len(valid_groups)):
- for jj in range(ii + 1, len(valid_groups)):
- try:
- _, ph_p = stats.mannwhitneyu(
- valid_groups[ii], valid_groups[jj],
- alternative='two-sided')
- # Bonferroni correction
- ph_p_adj = min(ph_p * n_comp, 1.0)
- sig = '*' if ph_p_adj < 0.05 else 'ns'
- result['posthoc'].append(
- f"{self.treatment_names[ii]} vs "
- f"{self.treatment_names[jj]}: p={ph_p_adj:.4f} {sig}")
- except Exception:
- pass
- # Decision
- if not np.isnan(result['p_value']):
- p = result['p_value']
- if p < 0.001:
- result['decision'] = 'Significant (p<0.001)'
- elif p < 0.01:
- result['decision'] = 'Significant (p<0.01)'
- elif p < 0.05:
- result['decision'] = 'Significant (p<0.05)'
- else:
- result['decision'] = 'Not significant'
- # Low N warning
- if min_n < 5:
- result['note'] += ' WARNING: Low sample size (n<5), interpret with caution.'
- return result
- # =====================================================================
- # FIGURE GENERATION METHODS
- # =====================================================================
- def _shaded_error_bar(self, ax, x, y, err, color, linewidth=1.5,
- alpha=0.15, marker=None, markersize=4, label=None):
- """
- Plot mean line with shaded SEM region.
- Returns the main line handle.
- """
- y = np.asarray(y).ravel()
- err = np.asarray(err).ravel()
- x = np.asarray(x).ravel()
- kwargs = dict(color=color, linewidth=linewidth, label=label)
- if marker:
- kwargs['marker'] = marker
- kwargs['markersize'] = markersize
- kwargs['markerfacecolor'] = color
- line, = ax.plot(x, y, **kwargs)
- ax.fill_between(x, y - err, y + err, color=color, alpha=alpha)
- return line
- def _add_significance_bracket(self, ax, x1, x2, p_val):
- """Draw a significance bracket between two bar positions."""
- yl = ax.get_ylim()
- y_top = yl[1] * 0.90
- y_bracket = y_top * 0.95
- ax.plot([x1, x1, x2, x2],
- [y_top * 0.88, y_bracket, y_bracket, y_top * 0.88],
- '-k', linewidth=1)
- if p_val < 0.001:
- stars = '***'
- elif p_val < 0.01:
- stars = '**'
- elif p_val < 0.05:
- stars = '*'
- else:
- stars = 'ns'
- ax.text((x1 + x2) / 2, y_bracket * 1.02, stars,
- ha='center', fontsize=10, fontweight='bold')
- def _ethogram_plot(self, ax, matrix_data, sub_idx, duration, code_col_idx):
- """
- Draw color-coded patches for each behavioral event.
- Code 1 (Chow) = Gold, Code 2 (HFD) = Red, Code 3 (Gnawing) = Black
- """
- y_center = -(sub_idx + 1) # Offset each subject downward
- beh_codes = [1, 2, 3]
- beh_colors = [
- (0.93, 0.69, 0.13), # Gold (Chow)
- (1.0, 0.0, 0.0), # Red (HFD)
- (0.0, 0.0, 0.0), # Black (Gnawing)
- ]
- if matrix_data.shape[1] > code_col_idx:
- codes = matrix_data[:, code_col_idx]
- for k, code in enumerate(beh_codes):
- rows = codes == code
- if not np.any(rows):
- continue
- intervals = matrix_data[rows][:, 0:2]
- for iv in intervals:
- rect = mpatches.Rectangle(
- (iv[0], y_center - 0.4), iv[1] - iv[0], 0.8,
- facecolor=beh_colors[k], edgecolor='none')
- ax.add_patch(rect)
- def _save_figure(self, fig, fig_label: str) -> None:
- """Save figure in PNG format to results folder."""
- base_name = f"{self.experiment_name}_{fig_label}"
- full_base = os.path.join(self.results_path, base_name)
- fig.savefig(f"{full_base}.png", dpi=300, bbox_inches='tight',
- facecolor='white')
- # ----- FIGURE 1: Ethograms + Cumulative Feeding -----
- def _fig1_ethograms_cumulative(self) -> plt.Figure:
- """Generate Figure 1: Ethograms and cumulative feeding per group."""
- # Compute global Y limit for cumulative plots
- global_max_y = 0
- for grp in self.groups:
- if grp.intake is not None and grp.intake.size > 0:
- cum_d = np.cumsum(grp.intake, axis=1)
- n_s = cum_d.shape[0]
- peak = np.max(np.mean(cum_d, axis=0) +
- np.std(cum_d, axis=0, ddof=0) / np.sqrt(max(n_s, 1)))
- if peak > global_max_y:
- global_max_y = peak
- if global_max_y == 0:
- global_max_y = 10
- global_ylim = (0, global_max_y * 1.15)
- fig, axes = plt.subplots(2, self.num_treatments,
- figsize=(5 * self.num_treatments, 6.5),
- squeeze=False)
- for i, grp in enumerate(self.groups):
- # Top row: ethograms
- ax_top = axes[0, i]
- ax_top.set_title(grp.name.replace('_', ' '), color=grp.color,
- fontweight='bold', fontsize=11)
- valid_subs = 0
- for sub in range(len(grp.DM)):
- if grp.DM[sub] is not None:
- self._ethogram_plot(ax_top, grp.DM[sub], sub,
- self.cfg.exp_duration, self.cfg.col_code)
- valid_subs = sub + 1
- ax_top.set_xlim(0, self.cfg.exp_duration)
- if valid_subs > 0:
- ax_top.set_ylim(-valid_subs - 1, 0)
- ax_top.set_yticks([])
- ax_top.set_xlabel('Time (s)')
- if i == 0:
- ax_top.set_ylabel('Subjects')
- ax_top.spines['top'].set_visible(False)
- ax_top.spines['right'].set_visible(False)
- # Bottom row: cumulative feeding
- ax_bot = axes[1, i]
- data_mat = grp.intake
- if data_mat is not None and data_mat.size > 0:
- cum_data = np.cumsum(data_mat, axis=1)
- mu = np.mean(cum_data, axis=0)
- sigma = np.std(cum_data, axis=0, ddof=0) / np.sqrt(max(cum_data.shape[0], 1))
- x_axis = np.arange(1, cum_data.shape[1] + 1) * self.cfg.bin_size
- x_hours = x_axis / 3600.0
- self._shaded_error_bar(ax_bot, x_hours, mu, sigma, grp.color)
- ax_bot.set_xlim(0, self.cfg.exp_duration / 3600.0)
- ax_bot.set_ylim(global_ylim)
- ax_bot.set_xlabel('Time (h)')
- if i == 0:
- ax_bot.set_ylabel('Cum. Feeding Time (s)')
- ax_bot.spines['top'].set_visible(False)
- ax_bot.spines['right'].set_visible(False)
- fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Ethograms & Cumulative Feeding",
- fontsize=13, fontweight='bold')
- fig.tight_layout(rect=[0, 0, 1, 0.95])
- self._save_figure(fig, 'Fig1_Ethogram')
- return fig
- # ----- FIGURE 2: Core Metrics -----
- def _fig2_core_metrics(self) -> plt.Figure:
- """Generate Figure 2: IBI time course, validation, feeding rate, bar charts."""
- fig = plt.figure(figsize=(14, 9))
- gs = fig.add_gridspec(3, 6, hspace=0.45, wspace=0.5)
- # --- 2A: IBI Time Course ---
- ax_ibi = fig.add_subplot(gs[0, 0:4])
- n_bins_ibi = int(self.cfg.exp_duration // self.cfg.ibi_window)
- handles_ibi = []
- for i, grp in enumerate(self.groups):
- raw_ibi = self.c_ibi_all[:, :, i]
- ibi_binned_mean = np.zeros(n_bins_ibi)
- ibi_binned_sem = np.zeros(n_bins_ibi)
- x_ibi = np.zeros(n_bins_ibi)
- for b in range(n_bins_ibi):
- idx_s = b * self.cfg.ibi_window
- idx_e = (b + 1) * self.cfg.ibi_window
- block = raw_ibi[:, idx_s:idx_e]
- x_ibi[b] = (idx_s + idx_e) / 2.0
- vals = block[~np.isnan(block)]
- if len(vals) > 0:
- ibi_binned_mean[b] = np.mean(vals)
- ibi_binned_sem[b] = np.std(vals, ddof=0) / np.sqrt(len(vals))
- x_hours = x_ibi / 3600.0
- h = ax_ibi.errorbar(x_hours, ibi_binned_mean, yerr=ibi_binned_sem,
- fmt='-o', color=grp.color, markerfacecolor='w',
- linewidth=1.2, markersize=5,
- label=grp.name.replace('_', ' '))
- handles_ibi.append(h)
- ax_ibi.set_ylabel('IBI (s)')
- ax_ibi.set_xlabel('Time (h)')
- ax_ibi.set_title('Inter-Bout Interval', fontsize=10)
- ax_ibi.set_xlim(0, self.cfg.exp_duration / 3600.0)
- ax_ibi.legend(fontsize=7, frameon=False, loc='best')
- ax_ibi.spines['top'].set_visible(False)
- ax_ibi.spines['right'].set_visible(False)
- # --- 2B: Validation (Regression) ---
- ax_reg = fig.add_subplot(gs[0, 4:6])
- if self.has_intake:
- all_weights = []
- all_times = []
- all_colors_reg = []
- for i, grp in enumerate(self.groups):
- total_time = np.sum(grp.intake, axis=1)
- weights = self._get_gt_for_group(grp, i)
- n_valid = min(len(total_time), len(weights))
- w = weights[:n_valid]
- t = total_time[:n_valid]
- keep = ~np.isnan(w) & ~np.isnan(t)
- w = w[keep]
- t = t[keep]
- if len(w) > 0:
- ax_reg.plot(w, t, 'o', color=grp.color,
- markerfacecolor=grp.color, markersize=6,
- label=grp.name.replace('_', ' '))
- all_weights.extend(w)
- all_times.extend(t)
- all_weights = np.array(all_weights)
- all_times = np.array(all_times)
- valid_idx = ~np.isnan(all_weights) & ~np.isnan(all_times)
- if np.any(valid_idx):
- w_v = all_weights[valid_idx]
- t_v = all_times[valid_idx]
- # Linear regression
- coeffs = np.polyfit(w_v, t_v, 1)
- y_fit = np.polyval(coeffs, w_v)
- ss_res = np.sum((t_v - y_fit) ** 2)
- ss_tot = np.sum((t_v - np.mean(t_v)) ** 2)
- r_sq = 1 - ss_res / ss_tot if ss_tot > 0 else 0
- x_line = np.linspace(np.min(w_v), np.max(w_v), 100)
- ax_reg.plot(x_line, np.polyval(coeffs, x_line), '-r', linewidth=1.5)
- ax_reg.text(0.05, 0.95, f'R² = {r_sq:.3f}',
- transform=ax_reg.transAxes, color='r',
- fontsize=10, fontweight='bold', va='top')
- ax_reg.set_xlabel('Manual Intake (g)')
- ax_reg.set_ylabel('Feeding Time (s)')
- ax_reg.set_title('Regression Validation', fontsize=10)
- ax_reg.legend(fontsize=7, frameon=False, loc='best')
- ax_reg.grid(True, alpha=0.3)
- else:
- ax_reg.text(0.5, 0.5, 'No Intake Data', ha='center', va='center',
- fontsize=11, color='gray', transform=ax_reg.transAxes)
- ax_reg.set_axis_off()
- # --- 2B2: Bland-Altman ---
- ax_ba = fig.add_subplot(gs[1, 4:6])
- if self.has_intake and len(all_weights) > 0:
- w_v = np.array(all_weights)
- t_v = np.array(all_times)
- valid = ~np.isnan(w_v) & ~np.isnan(t_v)
- if np.sum(valid) > 1:
- w_z = (w_v[valid] - np.mean(w_v[valid])) / (np.std(w_v[valid], ddof=1) + 1e-10)
- t_z = (t_v[valid] - np.mean(t_v[valid])) / (np.std(t_v[valid], ddof=1) + 1e-10)
- avg_ba = (w_z + t_z) / 2
- diff_ba = t_z - w_z
- mean_diff = np.mean(diff_ba)
- sd_diff = np.std(diff_ba, ddof=1)
- # Build color array per point
- color_list = []
- for i, grp in enumerate(self.groups):
- total_time = np.sum(grp.intake, axis=1)
- weights = self._get_gt_for_group(grp, i)
- n_valid = min(len(total_time), len(weights))
- w = weights[:n_valid]
- t = total_time[:n_valid]
- keep = ~np.isnan(w) & ~np.isnan(t)
- color_list.extend([grp.color] * int(np.sum(keep)))
- color_arr = np.array(color_list) if color_list else np.array([[0, 0, 0]])
- ax_ba.scatter(avg_ba, diff_ba, c=color_arr[:len(avg_ba)],
- s=50, edgecolors='k', linewidths=0.5)
- ax_ba.axhline(mean_diff, color='b', linewidth=1.2)
- ax_ba.axhline(mean_diff + 1.96 * sd_diff, color='r',
- linestyle='--', linewidth=1)
- ax_ba.axhline(mean_diff - 1.96 * sd_diff, color='r',
- linestyle='--', linewidth=1)
- ax_ba.set_xlabel('Mean (z-score)')
- ax_ba.set_ylabel('Difference (z-score)')
- ax_ba.set_title('Bland-Altman (normalized)', fontsize=10)
- ax_ba.text(0.05, 0.95, f'Bias = {mean_diff:.3f}',
- transform=ax_ba.transAxes, color='b', fontsize=8,
- fontweight='bold', va='top')
- ax_ba.grid(True, alpha=0.3)
- else:
- ax_ba.text(0.5, 0.5, 'No Intake Data', ha='center', va='center',
- fontsize=11, color='gray', transform=ax_ba.transAxes)
- ax_ba.set_axis_off()
- # --- 2C: Feeding Rate Time Course ---
- ax_tc = fig.add_subplot(gs[1, 0:4])
- for i, grp in enumerate(self.groups):
- raw = grp.intake
- if raw is None or raw.size == 0:
- continue
- group_factor = 10
- new_cols = raw.shape[1] // group_factor
- if new_cols == 0:
- continue
- binned_vals = np.zeros((raw.shape[0], new_cols))
- for k in range(new_cols):
- binned_vals[:, k] = np.sum(
- raw[:, k * group_factor:(k + 1) * group_factor], axis=1)
- mu = np.nanmean(binned_vals, axis=0)
- err = np.nanstd(binned_vals, axis=0, ddof=0) / np.sqrt(max(binned_vals.shape[0], 1))
- x_mins = np.linspace(0, self.cfg.exp_duration / 60.0, len(mu))
- self._shaded_error_bar(ax_tc, x_mins, mu, err, grp.color,
- marker='o', markersize=4,
- label=grp.name.replace('_', ' '))
- ax_tc.set_xlabel('Time (min)')
- ax_tc.set_ylabel('Feeding Time (s / 10 min)')
- ax_tc.set_title('Feeding Rate Time Course', fontsize=10)
- ax_tc.legend(fontsize=7, frameon=False, loc='best')
- ax_tc.spines['top'].set_visible(False)
- ax_tc.spines['right'].set_visible(False)
- # --- 2D: Bar charts for core metrics (6 panels) ---
- core_metrics_idx = [0, 1, 2, 3, 4, 5] # 0-based
- for m_i in range(6):
- ax = fig.add_subplot(gs[2, m_i])
- m = core_metrics_idx[m_i]
- for ii in range(self.num_treatments):
- vals = self.all_metrics[ii][:, m]
- vals_clean = vals[~np.isnan(vals)]
- if len(vals_clean) == 0:
- continue
- ax.bar(ii + 1, np.mean(vals_clean), color=self.col[ii],
- edgecolor='none', alpha=0.6)
- ax.errorbar(ii + 1, np.mean(vals_clean),
- yerr=np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)),
- fmt='none', ecolor='k', linewidth=1)
- # Scatter individual points
- jitter = (np.random.rand(len(vals_clean)) - 0.5) * 0.3
- ax.plot(ii + 1 + jitter, vals_clean, '.', color='0.3', markersize=6)
- ax.set_title(METRICS_DISPLAY[m], fontsize=9)
- ax.set_xticks(range(1, self.num_treatments + 1))
- ax.set_xticklabels(
- [n.replace('_', ' ') for n in self.treatment_names],
- rotation=45, ha='right', fontsize=7)
- ax.set_aspect('auto')
- # Significance bracket for 2-group comparisons
- if (len(self.stats_results) > m and
- self.stats_results[m]['p_value'] < 0.05 and
- self.num_treatments == 2):
- self._add_significance_bracket(ax, 1, 2, self.stats_results[m]['p_value'])
- fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Core Metrics",
- fontsize=13, fontweight='bold')
- fig.tight_layout(rect=[0, 0, 1, 0.95])
- self._save_figure(fig, 'Fig2_Metrics')
- return fig
- # ----- FIGURE 3: Food Type Analysis -----
- def _fig3_food_type(self) -> plt.Figure:
- """Generate Figure 3: Chow vs HFD cumulative, preference ratio, bars."""
- fig, axes = plt.subplots(1, 3, figsize=(12, 5))
- # --- 3A: Chow vs HFD time course ---
- ax = axes[0]
- for i, grp in enumerate(self.groups):
- # Chow cumulative
- cum_c = np.cumsum(grp.intake_chow, axis=1)
- mu_c = np.mean(cum_c, axis=0)
- x_h = np.arange(1, cum_c.shape[1] + 1) * self.cfg.bin_size / 3600.0
- ax.plot(x_h, mu_c, '-', color=grp.color, linewidth=1.5,
- label=f"{grp.name.replace('_', ' ')} Chow")
- # HFD cumulative
- cum_h = np.cumsum(grp.intake_hfd, axis=1)
- mu_h = np.mean(cum_h, axis=0)
- ax.plot(x_h, mu_h, '--', color=grp.color, linewidth=1.5,
- label=f"{grp.name.replace('_', ' ')} HFD")
- ax.set_xlabel('Time (h)')
- ax.set_ylabel('Cum. Feeding Time (s)')
- ax.set_title('Cumulative: Chow vs HFD', fontsize=10)
- ax.legend(fontsize=7, frameon=False, loc='best')
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- # --- 3B: Preference Ratio over time ---
- ax = axes[1]
- for i, grp in enumerate(self.groups):
- cum_c = np.cumsum(grp.intake_chow, axis=1)
- cum_h = np.cumsum(grp.intake_hfd, axis=1)
- total = cum_c + cum_h
- pref = cum_h / np.maximum(total, 1) # Avoid division by zero
- mu_p = np.nanmean(pref, axis=0)
- err_p = np.nanstd(pref, axis=0, ddof=0) / np.sqrt(max(pref.shape[0], 1))
- x_h = np.arange(1, pref.shape[1] + 1) * self.cfg.bin_size / 3600.0
- self._shaded_error_bar(ax, x_h, mu_p, err_p, grp.color,
- label=grp.name.replace('_', ' '))
- ax.axhline(0.5, color='k', linestyle='--', linewidth=0.8)
- ax.set_xlabel('Time (h)')
- ax.set_ylabel('HFD Preference Ratio')
- ax.set_title('Preference Ratio (HFD / Total)', fontsize=10)
- ax.set_ylim(0, 1)
- ax.legend(fontsize=7, frameon=False, loc='best')
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- # --- 3C: Chow vs HFD bar chart ---
- ax = axes[2]
- bar_width = 0.35
- for i, grp in enumerate(self.groups):
- chow_total = np.sum(grp.intake_chow, axis=1)
- hfd_total = np.sum(grp.intake_hfd, axis=1)
- x_pos = i + 1
- ax.bar(x_pos - bar_width / 2, np.mean(chow_total), bar_width,
- color=FOOD_COLORS['chow'], edgecolor='none', alpha=0.7)
- ax.bar(x_pos + bar_width / 2, np.mean(hfd_total), bar_width,
- color=FOOD_COLORS['hfd'], edgecolor='none', alpha=0.7)
- ax.errorbar(x_pos - bar_width / 2, np.mean(chow_total),
- yerr=np.std(chow_total, ddof=0) / np.sqrt(max(len(chow_total), 1)),
- fmt='none', ecolor='k', linewidth=1)
- ax.errorbar(x_pos + bar_width / 2, np.mean(hfd_total),
- yerr=np.std(hfd_total, ddof=0) / np.sqrt(max(len(hfd_total), 1)),
- fmt='none', ecolor='k', linewidth=1)
- ax.set_xticks(range(1, self.num_treatments + 1))
- ax.set_xticklabels([n.replace('_', ' ') for n in self.treatment_names],
- rotation=45, ha='right', fontsize=9)
- ax.set_ylabel('Total Feeding Time (s)')
- ax.set_title('Chow vs HFD by Group', fontsize=10)
- ax.legend(['Chow', 'HFD'], fontsize=8, frameon=False, loc='best')
- fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Food Type Analysis",
- fontsize=13, fontweight='bold')
- fig.tight_layout(rect=[0, 0, 1, 0.95])
- self._save_figure(fig, 'Fig3_FoodType')
- return fig
- # ----- FIGURE 4: Microstructure -----
- def _fig4_microstructure(self) -> plt.Figure:
- """Generate Figure 4: Bout/IBI distributions, meal metrics, activity heatmap."""
- fig = plt.figure(figsize=(14, 7))
- gs = fig.add_gridspec(2, 3, hspace=0.4, wspace=0.4)
- # --- 4A: Bout duration distribution (boxplot) ---
- ax = fig.add_subplot(gs[0, 0])
- all_bout_dur = []
- group_label_bout = []
- for i, grp in enumerate(self.groups):
- for s in range(grp.n_subs):
- bd = grp.bout_durations[s]
- if bd is not None and len(bd) > 0:
- all_bout_dur.extend(bd)
- group_label_bout.extend([i + 1] * len(bd))
- if all_bout_dur:
- bp_data = [[] for _ in range(self.num_treatments)]
- for val, lbl in zip(all_bout_dur, group_label_bout):
- bp_data[lbl - 1].append(val)
- bp = ax.boxplot(bp_data, patch_artist=True, sym='.',
- labels=[n.replace('_', ' ') for n in self.treatment_names])
- for patch, color in zip(bp['boxes'], self.col):
- patch.set_facecolor((*color, 0.5))
- ax.set_ylabel('Bout Duration (s)')
- ax.set_title('Bout Duration Distribution', fontsize=10)
- ax.tick_params(axis='x', rotation=45)
- # --- 4B: IBI distribution ---
- ax = fig.add_subplot(gs[0, 1])
- all_ibi = []
- group_label_ibi = []
- for i, grp in enumerate(self.groups):
- for s in range(grp.n_subs):
- ib = grp.ibi_values[s]
- if ib is not None and len(ib) > 0:
- all_ibi.extend(ib)
- group_label_ibi.extend([i + 1] * len(ib))
- if all_ibi:
- bp_data = [[] for _ in range(self.num_treatments)]
- for val, lbl in zip(all_ibi, group_label_ibi):
- bp_data[lbl - 1].append(val)
- bp = ax.boxplot(bp_data, patch_artist=True, sym='.',
- labels=[n.replace('_', ' ') for n in self.treatment_names])
- for patch, color in zip(bp['boxes'], self.col):
- patch.set_facecolor((*color, 0.5))
- ax.set_ylabel('IBI (s)')
- ax.set_title('IBI Distribution', fontsize=10)
- ax.tick_params(axis='x', rotation=45)
- # --- 4C: Meal metrics bars (Num_Meals and Meal_Size) ---
- meal_metrics_idx = [6, 7] # 0-based
- for m_i in range(2):
- ax = fig.add_subplot(gs[0, 2]) if m_i == 0 else fig.add_subplot(gs[1, 0])
- # Fix: use separate subplot for each
- if m_i == 1:
- ax = fig.add_subplot(gs[1, 0])
- m = meal_metrics_idx[m_i]
- for ii in range(self.num_treatments):
- vals = self.all_metrics[ii][:, m]
- vals_clean = vals[~np.isnan(vals)]
- if len(vals_clean) == 0:
- continue
- ax.bar(ii + 1, np.mean(vals_clean), color=self.col[ii],
- edgecolor='none', alpha=0.6)
- ax.errorbar(ii + 1, np.mean(vals_clean),
- yerr=np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)),
- fmt='none', ecolor='k', linewidth=1)
- jitter = (np.random.rand(len(vals_clean)) - 0.5) * 0.3
- ax.plot(ii + 1 + jitter, vals_clean, '.', color='0.3', markersize=8)
- ax.set_title(METRICS_DISPLAY[m], fontsize=9)
- ax.set_xticks(range(1, self.num_treatments + 1))
- ax.set_xticklabels(
- [n.replace('_', ' ') for n in self.treatment_names],
- rotation=45, ha='right', fontsize=8)
- # --- 4D: Activity heatmap ---
- ax = fig.add_subplot(gs[1, 1:3])
- all_heatmap_data = []
- heatmap_labels = []
- for i, grp in enumerate(self.groups):
- raw = grp.intake
- for s in range(raw.shape[0]):
- all_heatmap_data.append(raw[s, :])
- sname = os.path.splitext(grp.subjects[s])[0]
- heatmap_labels.append(
- f"{grp.name.replace('_', '')}_{sname}")
- if all_heatmap_data:
- heatmap_arr = np.array(all_heatmap_data)
- im = ax.imshow(heatmap_arr, aspect='auto', cmap='hot',
- interpolation='nearest')
- fig.colorbar(im, ax=ax, fraction=0.02, pad=0.04)
- ax.set_xlabel(f'Time bins ({self.cfg.bin_size} s)')
- ax.set_ylabel('Subject')
- ax.set_title('Feeding Activity Heatmap', fontsize=10)
- ax.set_yticks(range(len(heatmap_labels)))
- ax.set_yticklabels(heatmap_labels, fontsize=6)
- # Add group separators
- cum_n = 0
- for i in range(self.num_treatments - 1):
- cum_n += self.groups[i].n_subs
- ax.axhline(cum_n - 0.5, color='white', linewidth=2)
- fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Microstructure Analysis",
- fontsize=13, fontweight='bold')
- fig.tight_layout(rect=[0, 0, 1, 0.95])
- self._save_figure(fig, 'Fig4_Microstructure')
- return fig
- # ----- FIGURE 5: Advanced Analytics -----
- def _fig5_advanced(self) -> plt.Figure:
- """Generate Figure 5: Satiety ratio, bout duration vs time, curve fitting."""
- fig, axes = plt.subplots(1, 3, figsize=(12, 5))
- # --- 5A: Satiety Ratio bars ---
- ax = axes[0]
- m = 9 # Satiety_Ratio (0-based)
- for i in range(self.num_treatments):
- vals = self.all_metrics[i][:, m]
- vals_clean = vals[~np.isnan(vals)]
- if len(vals_clean) == 0:
- continue
- ax.bar(i + 1, np.mean(vals_clean), color=self.col[i],
- edgecolor='none', alpha=0.6)
- ax.errorbar(i + 1, np.mean(vals_clean),
- yerr=np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)),
- fmt='none', ecolor='k', linewidth=1)
- jitter = (np.random.rand(len(vals_clean)) - 0.5) * 0.3
- ax.plot(i + 1 + jitter, vals_clean, '.', color='0.3', markersize=8)
- ax.set_title('Satiety Ratio', fontsize=10)
- ax.set_ylabel('IBI_post / Bout Duration')
- ax.set_xticks(range(1, self.num_treatments + 1))
- ax.set_xticklabels([n.replace('_', ' ') for n in self.treatment_names],
- rotation=45, ha='right', fontsize=9)
- # --- 5B: Bout duration vs time (scatter) ---
- ax = axes[1]
- for i, grp in enumerate(self.groups):
- all_starts = []
- all_durs = []
- for s in range(grp.n_subs):
- dm = grp.DM[s]
- if dm is None:
- continue
- feed = dm[np.isin(dm[:, self.cfg.col_code], [1, 2])]
- if len(feed) > 0:
- all_starts.extend(feed[:, 0])
- all_durs.extend(feed[:, 2])
- if all_starts:
- ax.scatter(np.array(all_starts) / 3600.0, all_durs,
- s=15, c=[grp.color], alpha=0.4,
- label=grp.name.replace('_', ' '))
- ax.set_xlabel('Time (h)')
- ax.set_ylabel('Bout Duration (s)')
- ax.set_title('Bout Duration Over Time', fontsize=10)
- ax.set_xlim(0, self.cfg.exp_duration / 3600.0)
- ax.legend(fontsize=7, frameon=False, loc='best')
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- # --- 5C: Cumulative curve fitting ---
- ax = axes[2]
- for i, grp in enumerate(self.groups):
- raw = grp.intake
- if raw is None or raw.size == 0:
- continue
- cum_mean = np.mean(np.cumsum(raw, axis=1), axis=0)
- x_sec = np.arange(1, len(cum_mean) + 1) * self.cfg.bin_size
- x_h = x_sec / 3600.0
- ax.plot(x_h, cum_mean, 'o', color=grp.color, markersize=3,
- label=grp.name.replace('_', ' '))
- # Fit exponential saturation: y = a * (1 - exp(-b * x))
- try:
- def sat_func(x, a, b):
- return a * (1 - np.exp(-b * x))
- popt, _ = curve_fit(sat_func, x_sec, cum_mean,
- p0=[max(cum_mean) * 1.2, 1 / 1800.0],
- bounds=([0, 0], [np.inf, np.inf]),
- maxfev=5000)
- x_fine = np.linspace(0, self.cfg.exp_duration, 200)
- ax.plot(x_fine / 3600.0, sat_func(x_fine, *popt), '-',
- color=grp.color, linewidth=1.5)
- except Exception:
- pass
- ax.set_xlabel('Time (h)')
- ax.set_ylabel('Cum. Feeding Time (s)')
- ax.set_title('Cumulative Curve Fit', fontsize=10)
- ax.legend(fontsize=7, frameon=False, loc='best')
- ax.spines['top'].set_visible(False)
- ax.spines['right'].set_visible(False)
- fig.suptitle(f"{self.experiment_name.replace('_', ' ')} — Advanced Analytics",
- fontsize=13, fontweight='bold')
- fig.tight_layout(rect=[0, 0, 1, 0.95])
- self._save_figure(fig, 'Fig5_Advanced')
- return fig
- # =====================================================================
- # EXCEL EXPORT METHODS
- # =====================================================================
- def _export_summary_excel(self, xls_path: str) -> None:
- """Export Results Summary Excel (4 sheets)."""
- with pd.ExcelWriter(xls_path, engine='openpyxl') as writer:
- # --- Sheet 1: Individual Data ---
- rows = []
- for i, grp in enumerate(self.groups):
- for s in range(grp.n_subs):
- sname = os.path.splitext(grp.subjects[s])[0]
- row = {'Subject': sname, 'Treatment': grp.name}
- for m in range(N_METRICS):
- row[METRICS_LABELS[m]] = self.all_metrics[i][s, m]
- rows.append(row)
- pd.DataFrame(rows).to_excel(writer, sheet_name='Individual_Data', index=False)
- # --- Sheet 2: Group Stats ---
- rows = []
- for i in range(self.num_treatments):
- row = {'Treatment': self.treatment_names[i]}
- for m in range(N_METRICS):
- vals = self.all_metrics[i][:, m]
- vals_clean = vals[~np.isnan(vals)]
- if len(vals_clean) > 0:
- row[f'{METRICS_LABELS[m]}_Mean'] = np.mean(vals_clean)
- row[f'{METRICS_LABELS[m]}_SEM'] = (
- np.std(vals_clean, ddof=0) / np.sqrt(len(vals_clean)))
- else:
- row[f'{METRICS_LABELS[m]}_Mean'] = np.nan
- row[f'{METRICS_LABELS[m]}_SEM'] = np.nan
- rows.append(row)
- pd.DataFrame(rows).to_excel(writer, sheet_name='Group_Stats', index=False)
- # --- Sheet 3: Statistical Tests ---
- rows = []
- for m in range(N_METRICS):
- sr = self.stats_results[m]
- rows.append({
- 'Metric': METRICS_DISPLAY[m],
- 'Test': sr['test_name'],
- 'Statistic': sr['statistic'],
- 'P_Value': sr['p_value'],
- 'Decision': sr['decision'],
- 'Note': sr['note'],
- })
- pd.DataFrame(rows).to_excel(writer, sheet_name='Statistical_Tests', index=False)
- # --- Sheet 4: CV Table ---
- rows = []
- for i in range(self.num_treatments):
- row = {'Treatment': self.treatment_names[i]}
- for m in range(N_METRICS):
- row[f'{METRICS_LABELS[m]}_CV_pct'] = self.cv_table[i, m]
- rows.append(row)
- pd.DataFrame(rows).to_excel(writer, sheet_name='Variability_CV', index=False)
- def _export_deep_excel(self, xls_path: str) -> None:
- """Export Deep Analysis Data Excel (5 sheets)."""
- with pd.ExcelWriter(xls_path, engine='openpyxl') as writer:
- # --- Sheet 1: Raw Events ---
- rows = []
- for i, grp in enumerate(self.groups):
- for s in range(grp.n_subs):
- data = grp.DM[s]
- if data is None or data.shape[1] <= self.cfg.col_code:
- continue
- codes = data[:, self.cfg.col_code]
- valid = np.isin(codes, [1, 2, 3])
- if not np.any(valid):
- continue
- evs = data[valid]
- sname = os.path.splitext(grp.subjects[s])[0]
- for e in range(len(evs)):
- rows.append({
- 'Subject': sname,
- 'Treatment': grp.name,
- 'Start_s': evs[e, 0],
- 'End_s': evs[e, 1],
- 'Duration_s': evs[e, 2],
- 'Power_dB': evs[e, 4],
- 'Type_Code': int(evs[e, self.cfg.col_code]),
- })
- pd.DataFrame(rows).to_excel(writer, sheet_name='Events_Raw', index=False)
- # --- Sheet 2: Cumulative Time Course ---
- timeline_min = np.arange(1, self.n_bins + 1) * self.cfg.bin_size / 60.0
- cum_dict = {'Time_Min': timeline_min}
- for i, grp in enumerate(self.groups):
- cum_d = np.cumsum(grp.intake, axis=1)
- for s in range(cum_d.shape[0]):
- sname = os.path.splitext(grp.subjects[s])[0]
- col_name = f"{re.sub('[^a-zA-Z0-9]', '', grp.name)}_{sname}"
- cum_dict[col_name] = cum_d[s, :]
- pd.DataFrame(cum_dict).to_excel(
- writer, sheet_name='TimeCourse_Cumulative', index=False)
- # --- Sheet 3: Feeding Rate ---
- rate_dict = {'Time_Min': timeline_min}
- for i, grp in enumerate(self.groups):
- for s in range(grp.intake.shape[0]):
- sname = os.path.splitext(grp.subjects[s])[0]
- col_name = f"{re.sub('[^a-zA-Z0-9]', '', grp.name)}_{sname}"
- rate_dict[col_name] = grp.intake[s, :]
- pd.DataFrame(rate_dict).to_excel(
- writer, sheet_name='TimeCourse_FeedingRate', index=False)
- # --- Sheet 4: IBI Time Course ---
- n_bins_ibi = int(self.cfg.exp_duration // self.cfg.ibi_window)
- time_ibi = (np.arange(n_bins_ibi) * self.cfg.ibi_window +
- self.cfg.ibi_window / 2.0) / 60.0
- ibi_dict = {'Time_Min': time_ibi}
- for i, grp in enumerate(self.groups):
- raw_ibi = self.c_ibi_all[:, :, i]
- for s in range(grp.n_subs):
- subj_ibi_trace = np.full(n_bins_ibi, np.nan)
- for b in range(n_bins_ibi):
- idx_s = b * self.cfg.ibi_window
- idx_e = (b + 1) * self.cfg.ibi_window
- vals = raw_ibi[s, idx_s:idx_e]
- vals = vals[~np.isnan(vals)]
- if len(vals) > 0:
- subj_ibi_trace[b] = np.mean(vals)
- sname = os.path.splitext(grp.subjects[s])[0]
- col_name = f"{re.sub('[^a-zA-Z0-9]', '', grp.name)}_{sname}"
- ibi_dict[col_name] = subj_ibi_trace
- pd.DataFrame(ibi_dict).to_excel(
- writer, sheet_name='TimeCourse_IBI', index=False)
- # --- Sheet 5: Meal Data ---
- rows = []
- for i, grp in enumerate(self.groups):
- for s in range(grp.n_subs):
- meals = grp.meals[s]
- if meals is None or len(meals) == 0:
- continue
- sname = os.path.splitext(grp.subjects[s])[0]
- for meal_num in range(len(meals)):
- rows.append({
- 'Subject': sname,
- 'Treatment': grp.name,
- 'Meal_Num': meal_num + 1,
- 'Meal_Start_s': meals[meal_num, 0],
- 'Meal_End_s': meals[meal_num, 1],
- 'Meal_Duration_s': meals[meal_num, 2],
- 'Num_Bouts_In_Meal': int(meals[meal_num, 3]),
- })
- pd.DataFrame(rows).to_excel(writer, sheet_name='Meal_Clusters', index=False)
group_analysis.py at commit 63b05d6, under MIT · at the source
Overview
13 affiliations
- Laboratory Neurobiology of Appetite; Department of Pharmacology, CINVESTAV, Mexico City, Mexico
- Laboratory Neurobiology of Appetite; Center for Research on Aging (CIE), CINVESTAV, Mexico City, Mexico
- Facultad de Ingeniería, Universidad Nacional Autónoma de México, Mexico City, Mexico
- Department of Medicine, Duke University, Durham, United States
- Laboratory of Gut-Brain Neurobiology, Duke University, Durham, United States
- Department of Neurobiology, Duke University, Durham, United States
- Computational Genomics Division, National Institute of Genomic Medicine (INMEGEN), Mexico City, Mexico
- Center for Complexity Sciences, Universidad Nacional Autónoma de México, Mexico City, Mexico
- Department of Physiology, University of Arizona, Tucson, United States
- Department of Molecular Genetics and Microbiology, Duke University, Durham, United States
- Department of Pathology, Duke University, Durham, United States
- Department of Cell Biology, Duke University, Durham, United States
- Duke Institute for Brain Sciences, Duke University, Durham, United States
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/
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
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
- 27 September 2026: the link answers (HTTP 200)
RanierLabNeurobiologyAppetite2026/TheCrunchometerPy
63b05d6d0066b7277f2ef819fa61a238f406c206, 2 March 2026Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
49 files
- crunchometer_python/
analyze_real_video.py — Python, 87 lines - crunchometer_python/
app_paths.py — Python, 169 lines - crunchometer_python/
audio_extractor.py — Python, 652 lines - crunchometer_python/
audio_extractor_dialog.p — Python, 725 linesy - crunchometer_python/
audio_player.py — Python, 297 lines - crunchometer_python/
build_macos.sh — Shell, 235 lines - crunchometer_python/
calibration_dialog.py — Python, 1,223 lines - crunchometer_python/
converter_dialog.py — Python, 621 lines - crunchometer_python/
create_visualization.py — Python, 105 lines - crunchometer_python/
crunchometer/ — Python, 51 lines__init__.py - crunchometer_python/
crunchometer/ — Python, 61 linescore/ __init__.py - crunchometer_python/
crunchometer/ — Python, 563 lines, 1 matchcore/ analyzer.py - crunchometer_python/
crunchometer/ — Python, 1,182 lines, 1 matchcore/ audio.py - crunchometer_python/
crunchometer/ — Python, 381 lines, 1 matchcore/ classifier.py - crunchometer_python/
crunchometer/ — Python, 459 linescore/ colors.py - crunchometer_python/
crunchometer/ — Python, 589 linescore/ detection.py - crunchometer_python/
crunchometer/ — Python, 1,024 lines, 1 matchcore/ resnet_detector.py - crunchometer_python/
crunchometer/ — Python, 423 linescore/ results.py - crunchometer_python/
crunchometer/ — Python, 584 lines, 2 matchescore/ svm_detector.py - crunchometer_python/
crunchometer/ — Python, 229 linescore/ threshold_optimizer.py - crunchometer_python/
crunchometer/ — Python, 1,370 lines, 1 matchcore/ tracker.py - crunchometer_python/
crunchometer_gui.py — Python, 650 lines - crunchometer_python/
crunchometer_gui_pyqt.py — Python, 4,830 lines - crunchometer_python/
debug_mat.py — Python, 68 lines - crunchometer_python/
ethogram_plot.py — Python, 347 lines - crunchometer_python/
explore_trajectory.py — Python, 1,442 lines - crunchometer_python/
extract_final.py — Python, 133 lines - crunchometer_python/
extract_svm.py — Python, 123 lines - crunchometer_python/
feeding_bouts_video.py — Python, 627 lines - crunchometer_python/
full_pipeline.py — Python, 372 lines - crunchometer_python/
generate_intake_template — Python, 125 lines.py - crunchometer_python/
group_analysis.py — Python, 1,783 lines, 4 matches - crunchometer_python/
install_logo.py — Python, 60 lines - crunchometer_python/
motion_energy.py — Python, 245 lines, 2 matches - crunchometer_python/
plot_interaction_results — Python, 484 lines.py - crunchometer_python/
read_ecoc_params.py — Python, 139 lines - crunchometer_python/
rthook_cv2.py — Python, 36 lines - crunchometer_python/
rthook_qt_multimedia.py — Python, 32 lines - crunchometer_python/
snippet_maker.py — Python, 248 lines - crunchometer_python/
sorted_snippets.py — Python, 124 lines - crunchometer_python/
video_clipper.py — Python, 223 lines - crunchometer_python/
video_clipper_app.py — Python, 1,303 lines - crunchometer_python/
visual_validator.py — Python, 835 lines - crunchometer_python/
visual_validator_optimiz — Python, 1,033 linesed.py - export_ecoc_params.m — MATLAB, 165 lines
- export_svm_params.m — MATLAB, 91 lines
- inspect_matlab_svm.py — Python, 153 lines
- LICENSE.txt — License, 21 lines
- README.md — Text, 355 lines
Zenodo 8422691
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
- 27 September 2026: the link answers (HTTP 200)
11 files
- Catrex/
Add_ROIs.m — MATLAB, 26 lines - Catrex/
Read_Tiff_File.m — MATLAB, 100 lines - General/
Darken_Colors.m — MATLAB, 27 lines - General/
Find_Spikes.m — MATLAB, 61 lines - General/
Get_Color.m — MATLAB, 27 lines - General/
Read_Colors.m — MATLAB, 77 lines - General/
Set_Label_Time.m — MATLAB, 96 lines - Mouse face/
Motion_Energy.m — MATLAB, 32 lines - Mouse face/
Movie_Intensity.m — MATLAB, 24 lines - MoussionEnergy.m — MATLAB, 960 lines
- LICENSE — License, 674 lines
Zenodo 8423311
Availability: 1 check, the latest on 27 September 2026: the link answers (HTTP 200)
- 27 September 2026: the link answers (HTTP 200)
113 files
- Catrex/
Combine_Neurons.m — MATLAB, 26 lines - Catrex/
Evaluate_Neurons.m — MATLAB, 58 lines - Catrex/
Find_Cell_by_XY.m — MATLAB, 10 lines - Catrex/
Find_Duplicates.m — MATLAB, 16 lines - Catrex/
Get_Eccentricity.m — MATLAB, 30 lines - Catrex/
Get_Intersected_ROIs.m — MATLAB, 153 lines - Catrex/
Get_Neuronal_Masks.m — MATLAB, 54 lines - Catrex/
Get_Neuropil_Mask.m — MATLAB, 27 lines - Catrex/
Get_Overlaping.m — MATLAB, 20 lines - Catrex/
Get_ROIs_Image.m — MATLAB, 74 lines - Catrex/
Get_Raster_From_Inferenc — MATLAB, 53 linese.m - Catrex/
Get_Spike_Inference.m — MATLAB, 86 lines - Catrex/
Get_Stimulated_Frames.m — MATLAB, 24 lines - Catrex/
Get_Transients.m — MATLAB, 37 lines - Catrex/
Normalize_Movie.m — MATLAB, 43 lines - Catrex/
Plot_Transients.m — MATLAB, 139 lines - Catrex/
Preprocessing.m — MATLAB, 34 lines - Catrex/
Read_AVI_File.m — MATLAB, 21 lines - Catrex/
Read_Tiff_File.m — MATLAB, 100 lines - Catrex/
Select_Tuned_Neurons.m — MATLAB, 46 lines - Catrex/
Smooth_Transients.m — MATLAB, 13 lines - Catrex/
Sort_Neuron_Data.m — MATLAB, 12 lines - Deconvolution Caiman/
GetSn.m — MATLAB, 50 lines - Deconvolution Caiman/
estimate_time_constant.m — MATLAB, 67 lines - Deconvolution Caiman/
foopsi_oasisAR2.m — MATLAB, 162 lines - Deconvolution Caiman/
oasisAR1.m — MATLAB, 100 lines - Deconvolution Caiman/
oasisAR2.m — MATLAB, 156 lines - General/
Attenuate_Colors.m — MATLAB, 27 lines - General/
Circle_Mask.m — MATLAB, 19 lines - General/
Darken_Colors.m — MATLAB, 27 lines - General/
Get_Color.m — MATLAB, 27 lines - General/
Get_SEM.m — MATLAB, 21 lines - General/
Hold_Figure.m — MATLAB, 21 lines - General/
Hotelling_T2_Test.m — MATLAB, 113 lines - General/
Interpolate.m — MATLAB, 29 lines - General/
Plot_Area.m — MATLAB, 34 lines - General/
Plot_Raster.m — MATLAB, 110 lines - General/
Plot_Stimulation.m — MATLAB, 49 lines - General/
Read_Colors.m — MATLAB, 77 lines - General/
Reshape_Raster.m — MATLAB, 33 lines - General/
Set_Axes.m — MATLAB, 20 lines - General/
Set_Colormap_Blue_White_ — MATLAB, 18 linesRed.m - General/
Set_Figure.m — MATLAB, 37 lines - General/
Set_Label_Time.m — MATLAB, 96 lines - General/
Validate_Name.m — MATLAB, 21 lines - Motion correction/
Fast_Registration.m — MATLAB, 120 lines - Motion correction/
Fast_Tiff_Write.m — MATLAB, 243 lines - Motion correction/
Save_Tiff_Fast.m — MATLAB, 20 lines - Optogenetics/
Join_Optogenetics_File.m — MATLAB, 21 lines - Optogenetics/
Plot_Neurons_Stimulated. — MATLAB, 42 linesm - Optogenetics/
Read_Optogenetics_File.m — MATLAB, 97 lines - Optogenetics/
Write_XY_Prairie_Stim.m — MATLAB, 50 lines - Optogenetics/
xml2struct.m — MATLAB, 183 lines - Suite2P_JP/
Find_Cells_J2P.m — MATLAB, 261 lines - Suite2P_JP/
Get_Neuropil_Basis.m — MATLAB, 34 lines - Suite2P_JP/
Get_Spatial_Mask.m — MATLAB, 45 lines - Suite2P_JP/
getConnected.m — MATLAB, 18 lines - Suite2P_JP/
getIpix.m — MATLAB, 14 lines - Suite2P_JP/
my_conv.m — MATLAB, 20 lines - Suite2P_JP/
my_conv2_circ.m — MATLAB, 43 lines - Suite2P_JP/
my_max.m — MATLAB, 7 lines - Suite2P_JP/
my_min.m — MATLAB, 39 lines - Suite2P_JP/
my_min2.m — MATLAB, 31 lines - Tuning/
Get_Evoked_Neurons.m — MATLAB, 72 lines - Tuning/
Get_Ori_Cirvar.m — MATLAB, 83 lines - Tuning/
Get_Trial_Responses.m — MATLAB, 68 lines - Voltage recording/
Join_Voltage_Recordings. — MATLAB, 55 linesm - Voltage recording/
Read_Voltage_Recording.m — MATLAB, 100 lines - Xsembles/
Coactivity_Threshold.m — MATLAB, 21 lines - Xsembles/
Contrast_Index.m — MATLAB, 84 lines - Xsembles/
Contrast_Test.m — MATLAB, 80 lines - Xsembles/
Dendrogram_Node_Order.m — MATLAB, 123 lines - Xsembles/
Ensemble_Duration.m — MATLAB, 37 lines - Xsembles/
Filter_Raster_By_Network — MATLAB, 37 lines.m - Xsembles/
Find_Neurons_By_XY.m — MATLAB, 27 lines - Xsembles/
Find_Ori_Ensembles.m — MATLAB, 19 lines - Xsembles/
Find_Peaks.m — MATLAB, 291 lines - Xsembles/
Get_Adjacency_From_Raste — MATLAB, 40 linesr.m - Xsembles/
Get_EPI.m — MATLAB, 24 lines - Xsembles/
Get_Peak_Indices.m — MATLAB, 22 lines - Xsembles/
Get_Peak_Vectors.m — MATLAB, 68 lines - Xsembles/
Get_Peaks_Similarity.m — MATLAB, 30 lines - Xsembles/
Get_Xsemble_Activity.m — MATLAB, 31 lines - Xsembles/
Get_Xsemble_Neurons.m — MATLAB, 71 lines - Xsembles/
Get_Xsembles.m — MATLAB, 315 lines - Xsembles/
Highlight_Neurons_Select — MATLAB, 31 linesed.m - Xsembles/
Linkage_JP.m — MATLAB, 438 lines - Xsembles/
Neuronal_Network.m — MATLAB, 62 lines - Xsembles/
Neuronal_Network_Paralle — MATLAB, 73 linesl.m - Xsembles/
Pairwise_Coactivity.m — MATLAB, 16 lines - Xsembles/
Plot_Ensemble_Activity.m — MATLAB, 41 lines - Xsembles/
Plot_Ensemble_Similarity — MATLAB, 55 lines.m - Xsembles/
Plot_Ensemble_Structure. — MATLAB, 122 linesm - Xsembles/
Plot_Ensemble_Trials.m — MATLAB, 92 lines - Xsembles/
Plot_Neuron_Signal.m — MATLAB, 135 lines - Xsembles/
Plot_Neuron_Test.m — MATLAB, 60 lines - Xsembles/
Plot_Raster_And_External — MATLAB, 87 lines.m - Xsembles/
Plot_Xsemble_Raster.m — MATLAB, 236 lines - Xsembles/
Recreate_Movie.m — MATLAB, 30 lines - Xsembles/
Select_Best_Index.m — MATLAB, 51 lines - Xsembles/
Select_Neuron_Threshold. — MATLAB, 90 linesm - Xsembles/
Shuffle_Raster.m — MATLAB, 33 lines - Xsembles/
Similarity_Within_Raster — MATLAB, 20 liness.m - Xsembles/
Sort_Ensembles_By_EPI.m — MATLAB, 21 lines - Xsembles/
Sort_Neurons_By_Weights. — MATLAB, 20 linesm - Xsembles/
Sort_Raster.m — MATLAB, 23 lines - Xsembles/
Subnetworks.m — MATLAB, 23 lines - Xsembles/
Test_Ensemble_Similarity — MATLAB, 47 lines.m - Xsembles/
Vector_and_Neuron_ID.m — MATLAB, 70 lines - Xsembles_2P.m — MATLAB, 567 lines
- Xsembles_2P_Viewer.m — MATLAB, 1,653 lines
- LICENSE — License, 674 lines
- README.md — Text, 267 lines
perezortegaj/moussionenergy
51a2154f8e1b06dec210dad00f3eb200acaaaad4, 9 October 2023Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
11 files
- Catrex/
Add_ROIs.m — MATLAB, 26 lines - Catrex/
Read_Tiff_File.m — MATLAB, 100 lines - General/
Darken_Colors.m — MATLAB, 27 lines - General/
Find_Spikes.m — MATLAB, 61 lines - General/
Get_Color.m — MATLAB, 27 lines - General/
Read_Colors.m — MATLAB, 77 lines - General/
Set_Label_Time.m — MATLAB, 96 lines - Mouse face/
Motion_Energy.m — MATLAB, 32 lines - Mouse face/
Movie_Intensity.m — MATLAB, 24 lines - MoussionEnergy.m — MATLAB, 960 lines, 1 match
- LICENSE — License, 674 lines
perezortegaj/xsembles2p
274cdb9bce3c6eec0db909c9a40dd6a2f30cdc01, 24 April 2024Availability: 1 check, the latest on 27 September 2026: the link answers
- 27 September 2026: the link answers
114 files
- Catrex/
Combine_Neurons.m — MATLAB, 26 lines - Catrex/
Evaluate_Neurons.m — MATLAB, 58 lines - Catrex/
Find_Cell_by_XY.m — MATLAB, 10 lines - Catrex/
Find_Duplicates.m — MATLAB, 16 lines - Catrex/
Get_Eccentricity.m — MATLAB, 30 lines - Catrex/
Get_Intersected_ROIs.m — MATLAB, 153 lines - Catrex/
Get_Neuronal_Masks.m — MATLAB, 54 lines - Catrex/
Get_Neuropil_Mask.m — MATLAB, 27 lines - Catrex/
Get_Overlaping.m — MATLAB, 20 lines - Catrex/
Get_ROIs_Image.m — MATLAB, 74 lines - Catrex/
Get_Raster_From_Inferenc — MATLAB, 53 linese.m - Catrex/
Get_Spike_Inference.m — MATLAB, 86 lines - Catrex/
Get_Stimulated_Frames.m — MATLAB, 24 lines - Catrex/
Get_Transients.m — MATLAB, 37 lines - Catrex/
Normalize_Movie.m — MATLAB, 43 lines - Catrex/
Plot_Transients.m — MATLAB, 139 lines - Catrex/
Preprocessing.m — MATLAB, 34 lines - Catrex/
Read_AVI_File.m — MATLAB, 21 lines - Catrex/
Read_Tiff_File.m — MATLAB, 100 lines - Catrex/
Select_Tuned_Neurons.m — MATLAB, 46 lines - Catrex/
Smooth_Transients.m — MATLAB, 13 lines - Catrex/
Sort_Neuron_Data.m — MATLAB, 12 lines - Deconvolution Caiman/
GetSn.m — MATLAB, 50 lines - Deconvolution Caiman/
estimate_time_constant.m — MATLAB, 67 lines - Deconvolution Caiman/
foopsi_oasisAR2.m — MATLAB, 162 lines, 1 match - Deconvolution Caiman/
oasisAR1.m — MATLAB, 100 lines - Deconvolution Caiman/
oasisAR2.m — MATLAB, 156 lines - General/
Attenuate_Colors.m — MATLAB, 27 lines - General/
Circle_Mask.m — MATLAB, 19 lines - General/
Darken_Colors.m — MATLAB, 27 lines - General/
Get_Color.m — MATLAB, 27 lines - General/
Get_SEM.m — MATLAB, 21 lines - General/
Hold_Figure.m — MATLAB, 21 lines - General/
Hotelling_T2_Test.m — MATLAB, 113 lines - General/
Interpolate.m — MATLAB, 29 lines - General/
Plot_Area.m — MATLAB, 34 lines - General/
Plot_Raster.m — MATLAB, 110 lines - General/
Plot_Stimulation.m — MATLAB, 49 lines - General/
Read_Colors.m — MATLAB, 77 lines - General/
Reshape_Raster.m — MATLAB, 33 lines - General/
Set_Axes.m — MATLAB, 20 lines - General/
Set_Colormap_Blue_White_ — MATLAB, 18 linesRed.m - General/
Set_Figure.m — MATLAB, 37 lines - General/
Set_Label_Time.m — MATLAB, 96 lines - General/
Validate_Name.m — MATLAB, 21 lines - Motion correction/
Fast_Registration.m — MATLAB, 120 lines - Motion correction/
Fast_Tiff_Write.m — MATLAB, 243 lines - Motion correction/
Save_Tiff_Fast.m — MATLAB, 20 lines - Optogenetics/
Join_Optogenetics_File.m — MATLAB, 21 lines - Optogenetics/
Plot_Neurons_Stimulated. — MATLAB, 42 linesm - Optogenetics/
Read_Optogenetics_File.m — MATLAB, 97 lines - Optogenetics/
Write_XY_Prairie_Stim.m — MATLAB, 50 lines - Optogenetics/
xml2struct.m — MATLAB, 183 lines - Suite2P_JP/
Find_Cells_J2P.m — MATLAB, 261 lines - Suite2P_JP/
Get_Neuropil_Basis.m — MATLAB, 34 lines - Suite2P_JP/
Get_Spatial_Mask.m — MATLAB, 45 lines - Suite2P_JP/
getConnected.m — MATLAB, 18 lines - Suite2P_JP/
getIpix.m — MATLAB, 14 lines - Suite2P_JP/
my_conv.m — MATLAB, 20 lines - Suite2P_JP/
my_conv2_circ.m — MATLAB, 43 lines - Suite2P_JP/
my_max.m — MATLAB, 7 lines - Suite2P_JP/
my_min.m — MATLAB, 39 lines - Suite2P_JP/
my_min2.m — MATLAB, 31 lines - Tuning/
Get_Evoked_Neurons.m — MATLAB, 72 lines - Tuning/
Get_Ori_Cirvar.m — MATLAB, 83 lines - Tuning/
Get_Trial_Responses.m — MATLAB, 68 lines - Voltage recording/
Join_Voltage_Recordings. — MATLAB, 55 linesm - Voltage recording/
Read_Voltage_Recording.m — MATLAB, 100 lines - Xsembles/
Coactivity_Threshold.m — MATLAB, 21 lines - Xsembles/
Contrast_Index.m — MATLAB, 84 lines - Xsembles/
Contrast_Test.m — MATLAB, 80 lines - Xsembles/
Dendrogram_Node_Order.m — MATLAB, 123 lines - Xsembles/
Ensemble_Duration.m — MATLAB, 37 lines - Xsembles/
Filter_Raster_By_Network — MATLAB, 37 lines.m - Xsembles/
Find_Neurons_By_XY.m — MATLAB, 27 lines - Xsembles/
Find_Ori_Ensembles.m — MATLAB, 19 lines - Xsembles/
Find_Peaks.m — MATLAB, 291 lines - Xsembles/
Get_Adjacency_From_Raste — MATLAB, 40 linesr.m - Xsembles/
Get_EPI.m — MATLAB, 24 lines - Xsembles/
Get_Neuron_ID.m — MATLAB, 63 lines - Xsembles/
Get_Peak_Indices.m — MATLAB, 22 lines - Xsembles/
Get_Peak_Vectors.m — MATLAB, 68 lines - Xsembles/
Get_Peaks_Similarity.m — MATLAB, 30 lines - Xsembles/
Get_Xsemble_Activity.m — MATLAB, 31 lines - Xsembles/
Get_Xsemble_Neurons.m — MATLAB, 71 lines - Xsembles/
Get_Xsembles.m — MATLAB, 315 lines - Xsembles/
Highlight_Neurons_Select — MATLAB, 31 linesed.m - Xsembles/
Linkage_JP.m — MATLAB, 438 lines, 1 match - Xsembles/
Neuronal_Network.m — MATLAB, 62 lines - Xsembles/
Neuronal_Network_Paralle — MATLAB, 73 linesl.m - Xsembles/
Pairwise_Coactivity.m — MATLAB, 16 lines - Xsembles/
Plot_Ensemble_Activity.m — MATLAB, 41 lines - Xsembles/
Plot_Ensemble_Similarity — MATLAB, 55 lines.m - Xsembles/
Plot_Ensemble_Structure. — MATLAB, 122 linesm - Xsembles/
Plot_Ensemble_Trials.m — MATLAB, 92 lines - Xsembles/
Plot_Neuron_Signal.m — MATLAB, 135 lines - Xsembles/
Plot_Neuron_Test.m — MATLAB, 60 lines - Xsembles/
Plot_Raster_And_External — MATLAB, 87 lines.m - Xsembles/
Plot_Xsemble_Raster.m — MATLAB, 236 lines - Xsembles/
Recreate_Movie.m — MATLAB, 30 lines - Xsembles/
Select_Best_Index.m — MATLAB, 51 lines - Xsembles/
Select_Neuron_Threshold. — MATLAB, 90 linesm - Xsembles/
Shuffle_Raster.m — MATLAB, 33 lines - Xsembles/
Similarity_Within_Raster — MATLAB, 20 liness.m - Xsembles/
Sort_Ensembles_By_EPI.m — MATLAB, 21 lines - Xsembles/
Sort_Neurons_By_Weights. — MATLAB, 20 linesm - Xsembles/
Sort_Raster.m — MATLAB, 23 lines - Xsembles/
Subnetworks.m — MATLAB, 23 lines - Xsembles/
Test_Ensemble_Similarity — MATLAB, 47 lines.m - Xsembles/
Vector_and_Neuron_ID.m — MATLAB, 70 lines - Xsembles_2P.m — MATLAB, 567 lines, 1 match
- Xsembles_2P_Viewer.m — MATLAB, 1,722 lines, 1 match
- LICENSE — License, 674 lines
- README.md — Text, 271 lines
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://
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://
BibTeX
@article{gillievana2026c
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/
url = {https://
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/
VL - 14
SP - RP108663
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.7554/
"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":
"volume": "14",
"page": "RP108663",
"DOI": "10.7554/
"PMID": "42444443",
"PMCID": "PMC13368178",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://
"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 communicationsIn 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 methodsIn 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 neuroscienceIn 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 neuroscienceIn 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: iScienceIn 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 communicationsIn 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 endocrinologyIn 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 neuroscienceIn 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: NeuronIn 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.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 6 repositories of the authors' code, each at its verified commit and with its license, 290 scripts, and 18 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:a0ecc2c042525128…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
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.
