From 5f71c6eca984d3120f6b759e1b0c8b1ee66f94af Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:53:51 +0530 Subject: [PATCH 01/11] feat: add RateMoniter class --- brian2/monitors/ratemonitor.py | 111 +++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index 3a0a74576..76a1bef9e 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -2,8 +2,11 @@ Module defining `PopulationRateMonitor`. """ +from abc import ABC, abstractmethod + import numpy as np +from brian2.core.clocks import Clock from brian2.core.variables import Variables from brian2.groups.group import CodeRunner, Group from brian2.units.allunits import hertz, second @@ -16,6 +19,114 @@ logger = get_logger(__name__) +class RateMoniter(CodeRunner, Clock, ABC): + """ + Abstract base class for monitors that record rates. + """ + + @abstractmethod + @check_units(bin_size=second) + def binned(self, bin_size): + """ + Return the rate calculated in bins of a certain size. + + Parameters + ------------- + bin_size : `Quantity` + The size of the bins in seconds. Should be a multiple of dt. + + Returns + ------- + bins : `Quantity` + The midpoints of the bins. + binned_values : `Quantity` + The binned values. For EventMonitor subclasses, this is a 2D array + with shape (neurons, bins). For PopulationRateMonitor, this is a 1D array. + """ + raise NotImplementedError() + + @check_units(width=second) + def smooth_rates(self, window="gaussian", width=None): + """ + Returns a smooted out version of the firing rate(s). + + Parameters + ---------- + window : str, ndarray + The window to use for smoothing. Can be a string to chose a + predefined window(`flat` for a rectangular, and `gaussian` + for a Gaussian-shaped window). + + In this case the width of the window + is determined by the `width` argument. Note that for the Gaussian + window, the `width` parameter specifies the standard deviation of + the Gaussian, the width of the actual window is `4*width + dt` + (rounded to the nearest dt). For the flat window, the width is + rounded to the nearest odd multiple of dt to avoid shifting the rate + in time. + Alternatively, an arbitrary window can be given as a numpy array + (with an odd number of elements). In this case, the width in units + of time depends on the ``dt`` of the simulation, and no `width` + argument can be specified. The given window will be automatically + normalized to a sum of 1. + width : `Quantity`, optional + The width of the ``window`` in seconds (for a predefined window). + + Returns + ------- + rate : `Quantity` + The smoothed firing rate(s) in Hz. For EventMonitor subclasses, + this returns a 2D array with shape (neurons, time_bins). + For PopulationRateMonitor, this returns a 1D array. + Note that the rates are smoothed and not re-binned, i.e. the length + of the returned array is the same as the length of the binned data + and can be plotted against the bin centers from the ``binned`` method. + """ + if width is None and isinstance(window, str): + raise TypeError("Need a width when using a predefined window.") + if width is not None and not isinstance(window, str): + raise TypeError("Can only specify a width for a predefined window") + + if isinstance(window, str): + if window == "gaussian": + width_dt = int(np.round(2 * width / self.clock.dt)) + # Rounding only for the size of the window, not for the standard + # deviation of the Gaussian + window = np.exp( + -np.arange(-width_dt, width_dt + 1) ** 2 + * 1.0 + / (2 * (width / self.clock.dt) ** 2) + ) + elif window == "flat": + width_dt = int(np.round(width / (2 * self.clock.dt))) * 2 + 1 + used_width = width_dt * self.clock.dt + if abs(used_width - width) > 1e-6 * self.clock.dt: + logger.info( + f"width adjusted from {width} to {used_width}", + "adjusted_width", + once=True, + ) + window = np.ones(width_dt) + else: + raise NotImplementedError(f'Unknown pre-defined window "{window}"') + else: + try: + window = np.asarray(window) + except TypeError: + raise TypeError(f"Cannot use a window of type {type(window)}") + if window.ndim != 1: + raise TypeError("The provided window has to be one-dimensional.") + if len(window) % 2 != 1: + raise TypeError("The window has to have an odd number of values.") + + # Get the binned rates at the finest resolution + _, binned_values = self.binned(bin_size=self.clock.dt) + + # The actual smoothing + smoothed = np.convolve(binned_values, window * 1.0 / sum(window), mode="same") + return Quantity(smoothed, dim=hertz.dim) + + class PopulationRateMonitor(Group, CodeRunner): """ Record instantaneous firing rates, averaged across neurons from a From ce2a50a14cf314dbdd2a29a43383ded360799f2a Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:59:55 +0530 Subject: [PATCH 02/11] fix: proper class inheritance --- brian2/monitors/ratemonitor.py | 81 +--------------------------------- 1 file changed, 2 insertions(+), 79 deletions(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index 76a1bef9e..834f5ce2b 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -19,7 +19,7 @@ logger = get_logger(__name__) -class RateMoniter(CodeRunner, Clock, ABC): +class RateMoniter(CodeRunner, Group, Clock, ABC): """ Abstract base class for monitors that record rates. """ @@ -127,7 +127,7 @@ def smooth_rates(self, window="gaussian", width=None): return Quantity(smoothed, dim=hertz.dim) -class PopulationRateMonitor(Group, CodeRunner): +class PopulationRateMonitor(RateMoniter): """ Record instantaneous firing rates, averaged across neurons from a `NeuronGroup` or other spike source. @@ -211,83 +211,6 @@ def reinit(self): """ raise NotImplementedError() - @check_units(width=second) - def smooth_rate(self, window="gaussian", width=None): - """ - smooth_rate(self, window='gaussian', width=None) - - Return a smooth version of the population rate. - - Parameters - ---------- - window : str, ndarray - The window to use for smoothing. Can be a string to chose a - predefined window(``'flat'`` for a rectangular, and ``'gaussian'`` - for a Gaussian-shaped window). In this case the width of the window - is determined by the ``width`` argument. Note that for the Gaussian - window, the ``width`` parameter specifies the standard deviation of - the Gaussian, the width of the actual window is ``4*width + dt`` - (rounded to the nearest dt). For the flat window, the width is - rounded to the nearest odd multiple of dt to avoid shifting the rate - in time. - Alternatively, an arbitrary window can be given as a numpy array - (with an odd number of elements). In this case, the width in units - of time depends on the ``dt`` of the simulation, and no ``width`` - argument can be specified. The given window will be automatically - normalized to a sum of 1. - width : `Quantity`, optional - The width of the ``window`` in seconds (for a predefined window). - - Returns - ------- - rate : `Quantity` - The population rate in Hz, smoothed with the given window. Note that - the rates are smoothed and not re-binned, i.e. the length of the - returned array is the same as the length of the ``rate`` attribute - and can be plotted against the `PopulationRateMonitor` 's ``t`` - attribute. - """ - if width is None and isinstance(window, str): - raise TypeError("Need a width when using a predefined window.") - if width is not None and not isinstance(window, str): - raise TypeError("Can only specify a width for a predefined window") - - if isinstance(window, str): - if window == "gaussian": - width_dt = int(np.round(2 * width / self.clock.dt)) - # Rounding only for the size of the window, not for the standard - # deviation of the Gaussian - window = np.exp( - -np.arange(-width_dt, width_dt + 1) ** 2 - * 1.0 - / (2 * (width / self.clock.dt) ** 2) - ) - elif window == "flat": - width_dt = int(width / 2 / self.clock.dt) * 2 + 1 - used_width = width_dt * self.clock.dt - if abs(used_width - width) > 1e-6 * self.clock.dt: - logger.info( - f"width adjusted from {width} to {used_width}", - "adjusted_width", - once=True, - ) - window = np.ones(width_dt) - else: - raise NotImplementedError(f'Unknown pre-defined window "{window}"') - else: - try: - window = np.asarray(window) - except TypeError: - raise TypeError(f"Cannot use a window of type {type(window)}") - if window.ndim != 1: - raise TypeError("The provided window has to be one-dimensional.") - if len(window) % 2 != 1: - raise TypeError("The window has to have an odd number of values.") - return Quantity( - np.convolve(self.rate_, window * 1.0 / sum(window), mode="same"), - dim=hertz.dim, - ) - def __repr__(self): classname = self.__class__.__name__ return f"<{classname}, recording {self.source.name}>" From f73bab12ab0001db68590ff4068bd312fd7fb3d6 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 22 Jul 2025 18:34:22 +0530 Subject: [PATCH 03/11] feat: add binned method to PopulationRateMonitor --- brian2/monitors/ratemonitor.py | 52 ++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index 834f5ce2b..e892443b6 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -89,12 +89,15 @@ def smooth_rates(self, window="gaussian", width=None): if isinstance(window, str): if window == "gaussian": - width_dt = int(np.round(2 * width / self.clock.dt)) - # Rounding only for the size of the window, not for the standard + # basically Gaussian theoretically spans infinite time, but practically it falls off quickly, + # So we choose a window of +- 2*(Standard deviations), i.e 95% of gaussian curve + width_dt = int( + np.round(2 * width / self.clock.dt) + ) # Rounding only for the size of the window, not for the standard # deviation of the Gaussian window = np.exp( -np.arange(-width_dt, width_dt + 1) ** 2 - * 1.0 + * 1.0 # hack to ensure floating-point division :) / (2 * (width / self.clock.dt) ** 2) ) elif window == "flat": @@ -211,6 +214,49 @@ def reinit(self): """ raise NotImplementedError() + @check_units(bin_size=second) + def binned(self, bin_size): + """ + Return the population rate binned with the given bin size. + + Parameters + ---------- + bin_size : `Quantity` + The size of the bins in seconds. Should be a multiple of dt. + + Returns + ------- + bins : `Quantity` + The midpoints of the bins. + binned_values : `Quantity` + The binned population rates as a 1D array in Hz. + """ + if ( + bin_size / self.clock.dt + ) % 1 > 1e-6: # to make sure bin_size is an integer multiple of the internal time resolution dt + raise ValueError("bin_size has to be a multiple of dt.") + if bin_size == self.clock.dt: + return self.t[:], self.rate + + num_bins = int(self.clock.t / bin_size) + bins = ( + np.arange(num_bins) * bin_size + bin_size / 2.0 + ) # as we want Bin centers (not edges) + + t_indices = (self.t / self.clock.dt).astype(int) + bin_indices = (t_indices * self.clock.dt / bin_size).astype(int) + + binned_values = np.zeros(num_bins) # to store total firing rate values per bin + bin_counts = np.zeros(num_bins) # to store how many samples went into each bin + + np.add.at(binned_values, bin_indices, self.rate) + np.add.at(bin_counts, bin_indices, 1) + + # Avoid division by zero for empty bins + non_empty_bins = bin_counts > 0 + binned_values[non_empty_bins] /= bin_counts[non_empty_bins] + return bins, Quantity(binned_values, dim=hertz.dim) + def __repr__(self): classname = self.__class__.__name__ return f"<{classname}, recording {self.source.name}>" From ef56ce7df357834061372822366f793ff8107aa6 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 22 Jul 2025 18:35:09 +0530 Subject: [PATCH 04/11] feat: export ratemoniter --- brian2/monitors/ratemonitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index e892443b6..bae57b014 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -13,7 +13,7 @@ from brian2.units.fundamentalunits import Quantity, check_units from brian2.utils.logger import get_logger -__all__ = ["PopulationRateMonitor"] +__all__ = ["PopulationRateMonitor", "RateMoniter"] logger = get_logger(__name__) From 1b516068dae0c9523a58b720a708c5454a3abe8e Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:56:16 +0530 Subject: [PATCH 05/11] fix: spelling typo --- brian2/monitors/ratemonitor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index bae57b014..c944aef5a 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -13,13 +13,13 @@ from brian2.units.fundamentalunits import Quantity, check_units from brian2.utils.logger import get_logger -__all__ = ["PopulationRateMonitor", "RateMoniter"] +__all__ = ["PopulationRateMonitor", "RateMonitor"] logger = get_logger(__name__) -class RateMoniter(CodeRunner, Group, Clock, ABC): +class RateMonitor(CodeRunner, Group, Clock, ABC): """ Abstract base class for monitors that record rates. """ @@ -130,7 +130,7 @@ def smooth_rates(self, window="gaussian", width=None): return Quantity(smoothed, dim=hertz.dim) -class PopulationRateMonitor(RateMoniter): +class PopulationRateMonitor(RateMonitor): """ Record instantaneous firing rates, averaged across neurons from a `NeuronGroup` or other spike source. From 129d27b94ce58c0d8079a88241d650a16e1dc9c6 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Fri, 5 Sep 2025 23:45:06 +0530 Subject: [PATCH 06/11] fix: another naming typo --- brian2/monitors/ratemonitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index c944aef5a..9ad10cec0 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -46,7 +46,7 @@ def binned(self, bin_size): raise NotImplementedError() @check_units(width=second) - def smooth_rates(self, window="gaussian", width=None): + def smooth_rate(self, window="gaussian", width=None): """ Returns a smooted out version of the firing rate(s). From 066f06bbe2e1abc86161bb0eb80eeda41c22c814 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 14 Sep 2025 10:13:12 +0530 Subject: [PATCH 07/11] fix: typo --- brian2/monitors/ratemonitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index 9ad10cec0..8ef2db29c 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -48,7 +48,7 @@ def binned(self, bin_size): @check_units(width=second) def smooth_rate(self, window="gaussian", width=None): """ - Returns a smooted out version of the firing rate(s). + Returns a smoothed out version of the firing rate(s). Parameters ---------- From 63be5b7f4ddf6e30a27a33272e1f6a2173eb1f0d Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 14 Sep 2025 11:34:36 +0530 Subject: [PATCH 08/11] feat: add binned method for class EventMonitor --- brian2/monitors/ratemonitor.py | 5 +++ brian2/monitors/spikemonitor.py | 76 +++++++++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index 8ef2db29c..fb9b4238e 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -91,6 +91,11 @@ def smooth_rate(self, window="gaussian", width=None): if window == "gaussian": # basically Gaussian theoretically spans infinite time, but practically it falls off quickly, # So we choose a window of +- 2*(Standard deviations), i.e 95% of gaussian curve + + assert ( + width is not None + ), "Width cannot be None for gaussian window" # Make Pyright happy + width_dt = int( np.round(2 * width / self.clock.dt) ) # Rounding only for the size of the window, not for the standard diff --git a/brian2/monitors/spikemonitor.py b/brian2/monitors/spikemonitor.py index 5c899d4d5..0eaa7905e 100644 --- a/brian2/monitors/spikemonitor.py +++ b/brian2/monitors/spikemonitor.py @@ -7,13 +7,15 @@ from brian2.core.names import Nameable from brian2.core.spikesource import SpikeSource from brian2.core.variables import Variables -from brian2.groups.group import CodeRunner, Group -from brian2.units.fundamentalunits import Quantity +from brian2.groups.group import CodeRunner +from brian2.monitors.ratemonitor import RateMonitor +from brian2.units.allunits import hertz, second +from brian2.units.fundamentalunits import Quantity, check_units __all__ = ["EventMonitor", "SpikeMonitor"] -class EventMonitor(Group, CodeRunner): +class EventMonitor(RateMonitor): """ Record events from a `NeuronGroup` or another event source. @@ -383,6 +385,74 @@ def event_trains(self): """ return self.values("t") + @check_units(bin_size=second) + def binned(self, bin_size): + """ + Return the event rates binned with the given bin size. + + Parameters + ---------- + bin_size : `Quantity` + The size of the bins in seconds. Should be a multiple of dt. + + Returns + ------- + bins : `Quantity` + The midpoints of the bins. + binned_values : `Quantity` + The binned rates as a 2D array (neurons × bins) in Hz. + """ + if (bin_size / self.clock.dt) % 1 > 1e-6: + raise ValueError("bin_size has to be a multiple of dt.") + + # Get the total duration and number of bins + duration = self.clock.dt + num_bins = int(duration / bin_size) + bins = np.arange(num_bins) * bin_size + bin_size / 2 # As we want bin centers + + # Now we determine the number of neurons ( as the moniter can be only monitoring a subset of the actual Group of Neurons) + if hasattr(self.source, "start") and hasattr(self.source, "stop"): + # this is the case of monitoring a subgroup + num_neurons = self.source.stop - self.source.start + neuron_offset = ( + self.source.start + ) # needed for calulations as we want to know from which index to start the calculations for binning from + else: + # Case where we are monitoring the whole Group + num_neurons = len(self.source) + neuron_offset = 0 # no offset as we are monitoring the whole Group + + # Now we initialize the binned values array (neurons × bins) + binned_values = np.zeros((num_neurons, num_bins)) + + if self.record: + # Get the event times and indices + event_times = self.t[:] + event_indices = ( + self.i[:] - neuron_offset + ) # Adjust for subgroups as stated above + + bin_indices = (event_times / bin_size).astype(int) + + # Now this is the main core code , here we count the events in each bin that happened for each neuron + # Like after this we should have something like : + # Example : + # binned_values = [ + # [2.0, 0.0, 1.0, 0.0, 0.0], # Neuron 0: 2 in bin 0, 1 in bin 2 + # [0.0, 1.0, 0.0, 1.0, 0.0], # Neuron 1: 1 in bin 1, 1 in bin 3 + # [0.0, 2.0, 0.0, 0.0, 1.0] # Neuron 2: 2 in bin 1, 1 in bin 4 + # ] + for event_idx, neuron_idx in enumerate(event_indices): + if 0 <= neuron_idx < num_neurons: + bin_idx = bin_indices[event_idx] + if bin_idx < num_bins: # To handle edge case at the end + binned_values[neuron_idx, bin_idx] += 1 + + # Convert counts to rates (Hz) + binned_values = binned_values / float(bin_size) + + return bins, Quantity(binned_values, dim=hertz.dim) + @property def num_events(self): """ From 2668486172aa3cbd94a482174cb473b4b3c20aa4 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:39:05 +0530 Subject: [PATCH 09/11] fix: incorrect logic for convolving 2D spikes of event monitor --- brian2/monitors/__init__.py | 8 +++++++- brian2/monitors/ratemonitor.py | 22 ++++++++++++++++++++-- brian2/monitors/spikemonitor.py | 7 ++----- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/brian2/monitors/__init__.py b/brian2/monitors/__init__.py index f5f607ce7..59fba67cf 100644 --- a/brian2/monitors/__init__.py +++ b/brian2/monitors/__init__.py @@ -7,4 +7,10 @@ from .spikemonitor import * from .statemonitor import * -__all__ = ["SpikeMonitor", "EventMonitor", "StateMonitor", "PopulationRateMonitor"] +__all__ = [ + "RateMonitor", + "SpikeMonitor", + "EventMonitor", + "StateMonitor", + "PopulationRateMonitor", +] diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index fb9b4238e..6d02417ad 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -130,8 +130,26 @@ def smooth_rate(self, window="gaussian", width=None): # Get the binned rates at the finest resolution _, binned_values = self.binned(bin_size=self.clock.dt) - # The actual smoothing - smoothed = np.convolve(binned_values, window * 1.0 / sum(window), mode="same") + # Normalize the window + window = window * 1.0 / sum(window) + + # Extract the raw numpy array from the Quantity (if it is one) + if hasattr(binned_values, "dimensions"): + binned_array = np.asarray(binned_values) + else: + binned_array = binned_values + + # So we need to handle for both 1D (PopulationRateMonitor) and 2D (EventMonitor) cases separately as `np.convolve()` only works with 1D arrays + if binned_values.ndim == 1: + # PopulationRateMonitor case - 1D convolution + smoothed = np.convolve(binned_values, window, mode="same") + else: + # EventMonitor/SpikeMonitor case - convolve each neuron and then we return the smoothed 2D array ( neuron * bins ) + num_neurons, num_bins = binned_array.shape + smoothed = np.zeros((num_neurons, num_bins)) + for i in range(num_neurons): + smoothed[i, :] = np.convolve(binned_array[i, :], window, mode="same") + return Quantity(smoothed, dim=hertz.dim) diff --git a/brian2/monitors/spikemonitor.py b/brian2/monitors/spikemonitor.py index 0eaa7905e..56b18e840 100644 --- a/brian2/monitors/spikemonitor.py +++ b/brian2/monitors/spikemonitor.py @@ -406,7 +406,7 @@ def binned(self, bin_size): raise ValueError("bin_size has to be a multiple of dt.") # Get the total duration and number of bins - duration = self.clock.dt + duration = self.clock.t num_bins = int(duration / bin_size) bins = np.arange(num_bins) * bin_size + bin_size / 2 # As we want bin centers @@ -424,7 +424,6 @@ def binned(self, bin_size): # Now we initialize the binned values array (neurons × bins) binned_values = np.zeros((num_neurons, num_bins)) - if self.record: # Get the event times and indices event_times = self.t[:] @@ -433,7 +432,6 @@ def binned(self, bin_size): ) # Adjust for subgroups as stated above bin_indices = (event_times / bin_size).astype(int) - # Now this is the main core code , here we count the events in each bin that happened for each neuron # Like after this we should have something like : # Example : @@ -443,14 +441,13 @@ def binned(self, bin_size): # [0.0, 2.0, 0.0, 0.0, 1.0] # Neuron 2: 2 in bin 1, 1 in bin 4 # ] for event_idx, neuron_idx in enumerate(event_indices): - if 0 <= neuron_idx < num_neurons: + if 0 <= neuron_idx < num_neurons: # sanity check bin_idx = bin_indices[event_idx] if bin_idx < num_bins: # To handle edge case at the end binned_values[neuron_idx, bin_idx] += 1 # Convert counts to rates (Hz) binned_values = binned_values / float(bin_size) - return bins, Quantity(binned_values, dim=hertz.dim) @property From 6ef761ef22ccfcfeeeef1888e7ec1dffd1d3a3d0 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 14 Sep 2025 18:30:33 +0530 Subject: [PATCH 10/11] feat: add ratemonitor tests --- brian2/tests/test_monitor.py | 200 ++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 1 deletion(-) diff --git a/brian2/tests/test_monitor.py b/brian2/tests/test_monitor.py index efb2259b4..b6a82483e 100644 --- a/brian2/tests/test_monitor.py +++ b/brian2/tests/test_monitor.py @@ -588,7 +588,16 @@ def test_rate_monitor_smoothed_rate(): assert_allclose(smoothed[index - 2 : index + 3], 0.2 / defaultclock.dt) with catch_logs(log_level=logging.INFO): smoothed2 = r_mon.smooth_rate(window="flat", width=5.4 * defaultclock.dt) - assert_array_equal(smoothed, smoothed2) + + # The window size should round up to 7. + # So, the spike at 'index' should be spread over 7 bins. + # The center is at 'index', with 3 bins on each side. + assert_array_equal(smoothed2[: index - 3], 0 * Hz) + assert_array_equal(smoothed2[index + 4 :], 0 * Hz) + + # The value should be 1/dt spread over 7 bins. + expected_value = (1 / defaultclock.dt) / 7.0 + assert_allclose(smoothed2[index - 3 : index + 4], expected_value) ### Gaussian window width = 5 * defaultclock.dt @@ -684,6 +693,188 @@ def test_rate_monitor_subgroups_2(): assert_allclose(rate_3.rate, 1 / defaultclock.dt) +# ====== Tests to test the added rate monitoring functionality in monitors ===== + + +@pytest.mark.codegen_independent +def test_population_rate_monitor_binning(): + """Test binning functionality for PopulationRateMonitor.""" + # Create a group with regular spiking + N = 10 + rate = 100 * Hz + duration = 100 * ms + + G = NeuronGroup(N, "v : 1", threshold="rand() < rate*dt") + G.run_regularly("v = 0") # Dummy operation + + mon = PopulationRateMonitor(G) + + run(duration) + + # Test binning with different bin sizes + for bin_size in [1 * ms, 5 * ms, 10 * ms]: + bins, binned_rates = mon.binned(bin_size) + + # Check that bins are correct + expected_bins = np.arange(int(duration / bin_size)) * bin_size + bin_size / 2 + assert_allclose(bins, expected_bins) + + # Check that rates are reasonable (around N*rate) + assert np.mean(binned_rates) > 0 # Should have some spikes + assert np.mean(binned_rates) < N * rate * 2 # Sanity check + + # Test that bin_size must be multiple of dt + with pytest.raises(ValueError): + mon.binned(1.5 * defaultclock.dt) + + +@pytest.mark.codegen_independent +def test_spike_monitor_binning(): + """Test binning functionality for SpikeMonitor.""" + # Create neurons with known spike times + indices = [0, 0, 1, 1, 2] + times = [10, 30, 20, 40, 50] * ms + + G = SpikeGeneratorGroup(3, indices, times) + mon = SpikeMonitor(G) + + run(100 * ms) + + # Test binning with 20ms bins + bins, binned_rates = mon.binned(20 * ms) + + # Expected: 5 bins of 20ms each + assert len(bins) == 5 + assert bins[0] == 10 * ms # First bin center + + # Check spike counts/rates for each neuron + # Neuron 0: spikes at 10ms (bin 0) and 30ms (bin 1) + assert binned_rates[0, 0] == 50 * Hz # 1 spike / 20ms + assert binned_rates[0, 1] == 50 * Hz + assert np.sum(binned_rates[0, 2:]) == 0 + + # Neuron 1: spikes at 20ms (bin 1) and 40ms (bin 2) + assert binned_rates[1, 1] == 50 * Hz + assert binned_rates[1, 2] == 50 * Hz + + # Neuron 2: spike at 50ms (bin 2) + assert binned_rates[2, 2] == 50 * Hz + assert np.sum(binned_rates[2, [0, 1, 3, 4]]) == 0 + + +@pytest.mark.codegen_independent +def test_smooth_rates_spike_monitor(): + """Test smoothing functionality for SpikeMonitor.""" + # Create a neuron with a burst of spikes + times = np.arange(40, 61) * ms # Burst from 40-60ms + indices = np.zeros(21, dtype=int) + + G = SpikeGeneratorGroup(1, indices, times) + mon = SpikeMonitor(G) + + run(100 * ms) + + # Test Gaussian smoothing + width = 5 * ms + smoothed = mon.smooth_rate(window="gaussian", width=width) + + # Should be smooth and peak around the burst + assert smoothed.ndim == 2 # (neurons, time) + assert smoothed.shape[0] == 1 # One neuron + + # Peak should be around 50ms (middle of burst) + peak_time = np.argmax(smoothed[0]) * defaultclock.dt + assert 45 * ms < peak_time < 55 * ms + + # Test flat window smoothing + smoothed_flat = mon.smooth_rate(window="flat", width=10 * ms) + assert smoothed_flat.shape == smoothed.shape + + # Test custom window + custom_window = np.array([0.25, 0.5, 0.25]) + smoothed_custom = mon.smooth_rate(window=custom_window) + assert smoothed_custom.shape == smoothed.shape + + +@pytest.mark.codegen_independent +def test_smooth_rates_population_monitor(): + """Test that smooth_rates works correctly for PopulationRateMonitor.""" + N = 100 + + rate_val = 50 * Hz + G = NeuronGroup(N, "rate : Hz", threshold="rand() < rate*dt") + G.rate = rate_val + mon = PopulationRateMonitor(G) + run(200 * ms) + + # Test different smoothing windows + smoothed_gauss = mon.smooth_rate("gaussian", width=10 * ms) + smoothed_flat = mon.smooth_rate("flat", width=10 * ms) + + # Smoothed rates should have same length as original + assert len(smoothed_gauss) == len(mon.rate) + assert len(smoothed_flat) == len(mon.rate) + + # Smoothed version should have less variance + assert np.var(smoothed_gauss) < np.var(mon.rate) + assert np.var(smoothed_flat) < np.var(mon.rate) + + +@pytest.mark.codegen_independent +def test_subgroup_spike_monitor(): + """Test that binning works correctly with subgroups.""" + N = 10 + G = NeuronGroup(N, "v:1", threshold="i<5 and rand()<0.1") + + # Monitor only neurons 2-7 + subgroup = G[2:8] + mon = SpikeMonitor(subgroup) + + run(100 * ms) + + _, rates = mon.binned(10 * ms) + + # Should have 6 neurons (indices 2-7) + assert rates.shape[0] == 6 + + # Only neurons 0,1,2 of subgroup (i.e., 2,3,4 of full group) can spike + if mon.num_spikes > 0: + assert np.all(mon.i[:] < 5) # In original indices + # In binned output, only first 3 rows can be non-zero + assert np.sum(rates[3:, :]) == 0 + + +@pytest.mark.codegen_independent +def test_rate_monitor_errors(): + """Test error conditions.""" + G = NeuronGroup( + 10, "v : 1", threshold="v > 1000" + ) # Very high threshold , as we don;t need actual spikes + mon = SpikeMonitor(G) + + run(10 * ms) + + # Test that width is required for predefined windows + with pytest.raises(TypeError): + mon.smooth_rate("gaussian") + + # Test that width cannot be specified for custom windows + with pytest.raises(TypeError): + mon.smooth_rate(np.array([1, 2, 1]), width=5 * ms) + + # Test unknown window type + with pytest.raises(NotImplementedError): + mon.smooth_rate("unknown", width=5 * ms) + + # Test that custom window must be 1D + with pytest.raises(TypeError): + mon.smooth_rate(np.array([[1, 2], [3, 4]])) + + # Test that custom window must have odd length + with pytest.raises(TypeError): + mon.smooth_rate(np.array([1, 2, 3, 4])) + + @pytest.mark.codegen_independent def test_monitor_str_repr(): # Basic test that string representations are not empty @@ -725,3 +916,10 @@ def test_monitor_str_repr(): test_rate_monitor_subgroups() test_rate_monitor_subgroups_2() test_monitor_str_repr() + # Rate moniter test + test_population_rate_monitor_binning() + test_spike_monitor_binning() + test_smooth_rates_spike_monitor() + test_smooth_rates_population_monitor() + test_subgroup_spike_monitor() + test_rate_monitor_errors() From f2aad637429c5ebad520a584fee724257254c423 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:09:17 +0530 Subject: [PATCH 11/11] fix: trying to figure out why testsuite ci fails --- brian2/monitors/ratemonitor.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/brian2/monitors/ratemonitor.py b/brian2/monitors/ratemonitor.py index 6d02417ad..8d670d9a3 100644 --- a/brian2/monitors/ratemonitor.py +++ b/brian2/monitors/ratemonitor.py @@ -92,10 +92,6 @@ def smooth_rate(self, window="gaussian", width=None): # basically Gaussian theoretically spans infinite time, but practically it falls off quickly, # So we choose a window of +- 2*(Standard deviations), i.e 95% of gaussian curve - assert ( - width is not None - ), "Width cannot be None for gaussian window" # Make Pyright happy - width_dt = int( np.round(2 * width / self.clock.dt) ) # Rounding only for the size of the window, not for the standard