bluecellulab 2.6.61__py3-none-any.whl → 2.6.63__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of bluecellulab might be problematic. Click here for more details.

@@ -4,9 +4,11 @@ try:
4
4
  except ImportError:
5
5
  efel = None
6
6
  from itertools import islice
7
+ from itertools import repeat
7
8
  import logging
8
9
  from matplotlib.collections import LineCollection
9
10
  import matplotlib.pyplot as plt
11
+ from multiprocessing import Pool
10
12
  import neuron
11
13
  import numpy as np
12
14
  import pathlib
@@ -40,7 +42,8 @@ def compute_plot_iv_curve(cell,
40
42
  show_figure=True,
41
43
  save_figure=False,
42
44
  output_dir="./",
43
- output_fname="iv_curve.pdf"):
45
+ output_fname="iv_curve.pdf",
46
+ n_processes=None):
44
47
  """Compute and plot the Current-Voltage (I-V) curve for a given cell by
45
48
  injecting a range of currents.
46
49
 
@@ -72,6 +75,9 @@ def compute_plot_iv_curve(cell,
72
75
  save_figure (bool): Whether to save the figure. Default is False.
73
76
  output_dir (str): The directory to save the figure if save_figure is True. Default is "./".
74
77
  output_fname (str): The filename to save the figure as if save_figure is True. Default is "iv_curve.png".
78
+ n_processes (int, optional): The number of processes to use for parallel execution.
79
+ If None or if it is higher than the number of steps,
80
+ it will use the number of steps as the number of processes.
75
81
 
76
82
  Returns:
77
83
  tuple: A tuple containing:
@@ -95,15 +101,22 @@ def compute_plot_iv_curve(cell,
95
101
  for amp in list_amp
96
102
  ]
97
103
 
98
- recordings = []
99
- for step in steps:
100
- recording = run_stimulus(cell.template_params,
101
- step,
102
- section=injecting_section,
103
- segment=injecting_segment,
104
- recording_section=recording_section,
105
- recording_segment=recording_segment)
106
- recordings.append(recording)
104
+ if n_processes is None or n_processes > len(steps):
105
+ n_processes = len(steps)
106
+ with Pool(n_processes) as p:
107
+ recordings = p.starmap(
108
+ run_stimulus,
109
+ zip(
110
+ repeat(cell.template_params),
111
+ steps,
112
+ repeat(injecting_section),
113
+ repeat(injecting_segment),
114
+ repeat(True), # cvode
115
+ repeat(True), # add_hypamp
116
+ repeat(recording_section),
117
+ repeat(recording_segment),
118
+ )
119
+ )
107
120
 
108
121
  steady_states = []
109
122
  # compute steady state response
@@ -148,7 +161,8 @@ def compute_plot_fi_curve(cell,
148
161
  show_figure=True,
149
162
  save_figure=False,
150
163
  output_dir="./",
151
- output_fname="fi_curve.pdf"):
164
+ output_fname="fi_curve.pdf",
165
+ n_processes=None):
152
166
  """Compute and plot the Frequency-Current (F-I) curve for a given cell by
153
167
  injecting a range of currents.
154
168
 
@@ -182,6 +196,9 @@ def compute_plot_fi_curve(cell,
182
196
  save_figure (bool): Whether to save the figure. Default is False.
183
197
  output_dir (str): The directory to save the figure if save_figure is True. Default is "./".
184
198
  output_fname (str): The filename to save the figure as if save_figure is True. Default is "iv_curve.png".
199
+ n_processes (int, optional): The number of processes to use for parallel execution.
200
+ If None or if it is higher than the number of steps,
201
+ it will use the number of steps as the number of processes.
185
202
 
186
203
  Returns:
187
204
  tuple: A tuple containing:
@@ -201,19 +218,26 @@ def compute_plot_fi_curve(cell,
201
218
  for amp in list_amp
202
219
  ]
203
220
 
204
- spikes = []
205
- for step in steps:
206
- recording = run_stimulus(cell.template_params,
207
- step,
208
- section=injecting_section,
209
- segment=injecting_segment,
210
- recording_section=recording_section,
211
- recording_segment=recording_segment,
212
- enable_spike_detection=True,
213
- threshold_spike_detection=threshold_voltage)
214
- spikes.append(recording.spike)
215
-
216
- spike_count = [len(spike) for spike in spikes]
221
+ if n_processes is None or n_processes > len(steps):
222
+ n_processes = len(steps)
223
+ with Pool(n_processes) as p:
224
+ recordings = p.starmap(
225
+ run_stimulus,
226
+ zip(
227
+ repeat(cell.template_params),
228
+ steps,
229
+ repeat(injecting_section),
230
+ repeat(injecting_segment),
231
+ repeat(True), # cvode
232
+ repeat(True), # add_hypamp
233
+ repeat(recording_section),
234
+ repeat(recording_segment),
235
+ repeat(True), # enable_spike_detection
236
+ repeat(threshold_voltage), # threshold_spike_detection
237
+ )
238
+ )
239
+
240
+ spike_count = [len(recording.spike) for recording in recordings]
217
241
 
218
242
  plot_fi_curve(list_amp,
219
243
  spike_count,
@@ -68,6 +68,14 @@ class SimulationConfig(Protocol):
68
68
  def spike_location(self) -> str:
69
69
  raise NotImplementedError
70
70
 
71
+ @property
72
+ def tstart(self) -> Optional[float]:
73
+ raise NotImplementedError
74
+
75
+ @property
76
+ def tstop(self) -> Optional[float]:
77
+ raise NotImplementedError
78
+
71
79
  @property
72
80
  def duration(self) -> Optional[float]:
73
81
  raise NotImplementedError
@@ -17,6 +17,7 @@ import json
17
17
  import logging
18
18
  from pathlib import Path
19
19
  from typing import Optional
20
+ import warnings
20
21
 
21
22
  from bluecellulab.circuit.config.sections import Conditions, ConnectionOverrides
22
23
  from bluecellulab.stimulus.circuit_stimulus_definitions import Stimulus
@@ -160,9 +161,21 @@ class SonataSimulationConfig:
160
161
  return self.impl.conditions.spike_location.name
161
162
 
162
163
  @property
163
- def duration(self) -> Optional[float]:
164
+ def tstart(self) -> Optional[float]:
165
+ return self.impl.config.get("run", {}).get("tstart", 0.0)
166
+
167
+ @property
168
+ def tstop(self) -> float:
164
169
  return self.impl.run.tstop
165
170
 
171
+ @property
172
+ def duration(self) -> Optional[float]:
173
+ warnings.warn(
174
+ "`duration` is deprecated. Use `tstop` instead.",
175
+ DeprecationWarning
176
+ )
177
+ return self.tstop
178
+
166
179
  @property
167
180
  def dt(self) -> float:
168
181
  return self.impl.run.dt
@@ -172,16 +172,38 @@ class SonataSimulationAccess:
172
172
 
173
173
 
174
174
  def get_synapse_replay_spikes(f_name: str) -> dict:
175
- """Read the .h5 file containing the spike replays."""
175
+ """Read the .h5 file containing the spike replays.
176
+
177
+ Args:
178
+ f_name: Path to SONATA .h5 spike file.
179
+
180
+ Returns:
181
+ Dictionary mapping node_id to np.array of spike times.
182
+ """
183
+ all_spikes = []
176
184
  with h5py.File(f_name, 'r') as f:
177
- # Access the timestamps and node_ids datasets
178
- timestamps = f['/spikes/All/timestamps'][:]
179
- node_ids = f['/spikes/All/node_ids'][:]
185
+ if "spikes" not in f:
186
+ raise ValueError("spike file is missing required 'spikes' group.")
180
187
 
181
- spikes = pd.DataFrame(data={'t': timestamps, 'node_id': node_ids})
182
- spikes = spikes.astype({"node_id": int})
188
+ for population in f["spikes"]:
189
+ pop_group = f["spikes"][population]
190
+ timestamps = pop_group["timestamps"][:]
191
+ node_ids = pop_group["node_ids"][:]
192
+
193
+ pop_spikes = pd.DataFrame({"t": timestamps, "node_id": node_ids})
194
+ pop_spikes = pop_spikes.astype({"node_id": int})
195
+ all_spikes.append(pop_spikes)
196
+
197
+ if not all_spikes:
198
+ return {}
199
+
200
+ spikes = pd.concat(all_spikes, ignore_index=True)
183
201
 
184
202
  if (spikes["t"] < 0).any():
185
203
  logger.warning("Found negative spike times... Clipping them to 0")
186
204
  spikes["t"].clip(lower=0., inplace=True)
187
- return spikes.groupby("node_id")["t"].apply(np.array).to_dict()
205
+
206
+ # Group spikes by node_id and ensure spike times are sorted in ascending order.
207
+ # This is critical because NEURON's VecStim requires monotonically increasing times per train.
208
+ grouped = spikes.groupby("node_id")["t"]
209
+ return {k: np.sort(np.asarray(v.values)) for k, v in grouped}
@@ -17,12 +17,11 @@ simulations."""
17
17
 
18
18
  from __future__ import annotations
19
19
  from collections.abc import Iterable
20
- import os
21
20
  from pathlib import Path
22
21
  from typing import Optional
23
22
  import logging
24
23
 
25
- from collections import defaultdict
24
+ from bluecellulab.reports.utils import configure_all_reports
26
25
  import neuron
27
26
  import numpy as np
28
27
  import pandas as pd
@@ -47,7 +46,6 @@ from bluecellulab.circuit.simulation_access import BluepySimulationAccess, Simul
47
46
  from bluecellulab.importer import load_mod_files
48
47
  from bluecellulab.rngsettings import RNGSettings
49
48
  from bluecellulab.simulation.neuron_globals import NeuronGlobals
50
- from bluecellulab.simulation.report import configure_all_reports, write_compartment_report, write_sonata_spikes
51
49
  from bluecellulab.stimulus.circuit_stimulus_definitions import Noise, OrnsteinUhlenbeck, RelativeOrnsteinUhlenbeck, RelativeShotNoise, ShotNoise
52
50
  import bluecellulab.stimulus.circuit_stimulus_definitions as circuit_stimulus_definitions
53
51
  from bluecellulab.exceptions import BluecellulabError
@@ -638,15 +636,32 @@ class CircuitSimulation:
638
636
  will not be exactly reproduced.
639
637
  """
640
638
  if t_stop is None:
641
- duration = self.circuit_access.config.duration
642
- if duration is None: # type narrowing
639
+ t_stop = self.circuit_access.config.tstop
640
+ if t_stop is None: # type narrowing
643
641
  t_stop = 0.0
644
- else:
645
- t_stop = duration
646
642
  if dt is None:
647
643
  dt = self.circuit_access.config.dt
648
- if forward_skip_value is None:
649
- forward_skip_value = self.circuit_access.config.forward_skip
644
+
645
+ config_forward_skip_value = self.circuit_access.config.forward_skip # legacy
646
+ config_tstart = self.circuit_access.config.tstart or 0.0 # SONATA
647
+ # Determine effective skip value and flag
648
+ if forward_skip_value is not None:
649
+ # User explicitly provided value → use it
650
+ effective_skip_value = forward_skip_value
651
+ effective_skip = forward_skip
652
+ elif config_forward_skip_value is not None:
653
+ # Use legacy config if available
654
+ effective_skip_value = config_forward_skip_value
655
+ effective_skip = forward_skip
656
+ elif config_tstart > 0.0:
657
+ # Use SONATA tstart *only* if no other skip value was provided
658
+ effective_skip_value = config_tstart
659
+ effective_skip = True
660
+ else:
661
+ # No skip
662
+ effective_skip_value = None
663
+ effective_skip = False
664
+
650
665
  if celsius is None:
651
666
  celsius = self.circuit_access.config.celsius
652
667
  NeuronGlobals.get_instance().temperature = celsius
@@ -664,15 +679,13 @@ class CircuitSimulation:
664
679
  "simulations")
665
680
 
666
681
  sim.run(
667
- t_stop,
682
+ tstop=t_stop,
668
683
  cvode=cvode,
669
684
  dt=dt,
670
- forward_skip=forward_skip,
671
- forward_skip_value=forward_skip_value,
685
+ forward_skip=effective_skip,
686
+ forward_skip_value=effective_skip_value,
672
687
  show_progress=show_progress)
673
688
 
674
- self.write_reports()
675
-
676
689
  def get_mainsim_voltage_trace(
677
690
  self, cell_id: int | tuple[str, int], t_start=None, t_stop=None, t_step=None
678
691
  ) -> np.ndarray:
@@ -713,23 +726,31 @@ class CircuitSimulation:
713
726
  first_key = next(iter(self.cells))
714
727
  return self.cells[first_key].get_time()
715
728
 
716
- def get_time_trace(self, t_step=None) -> np.ndarray:
729
+ def get_time_trace(self, t_start=None, t_stop=None, t_step=None) -> np.ndarray:
717
730
  """Get the time vector for the recordings, negative times removed.
718
731
 
719
732
  Parameters
720
733
  -----------
721
- t_step: time step (should be a multiple of report time step T;
722
- equals T by default)
734
+ t_start, t_stop: time range of interest.
735
+ t_step: time step (multiple of report dt; equals dt by default)
723
736
 
724
737
  Returns:
725
- One dimentional np.ndarray to represent the times.
738
+ 1D np.ndarray representing time points.
726
739
  """
727
740
  time = self.get_time()
728
- time = time[np.where(time >= 0.0)]
741
+ time = time[time >= 0.0]
742
+
743
+ if t_start is None or t_start < 0:
744
+ t_start = 0
745
+ if t_stop is None:
746
+ t_stop = np.inf
747
+
748
+ time = time[(time >= t_start) & (time <= t_stop)]
729
749
 
730
750
  if t_step is not None:
731
751
  ratio = t_step / self.dt
732
752
  time = _sample_array(time, ratio)
753
+
733
754
  return time
734
755
 
735
756
  def get_voltage_trace(
@@ -806,78 +827,3 @@ class CircuitSimulation:
806
827
  record_dt=cell_kwargs['record_dt'],
807
828
  template_format=cell_kwargs['template_format'],
808
829
  emodel_properties=cell_kwargs['emodel_properties'])
809
-
810
- def write_reports(self):
811
- """Write all reports defined in the simulation config."""
812
- report_entries = self.circuit_access.config.get_report_entries()
813
-
814
- for report_name, report_cfg in report_entries.items():
815
- report_type = report_cfg.get("type", "compartment")
816
- section = report_cfg.get("sections")
817
-
818
- if report_type != "compartment":
819
- raise NotImplementedError(f"Report type '{report_type}' is not supported.")
820
-
821
- output_path = self.circuit_access.config.report_file_path(report_cfg, report_name)
822
- if section == "compartment_set":
823
- if report_cfg.get("cells") is not None:
824
- raise ValueError(
825
- "Report config error: 'cells' must not be set when using 'compartment_set' sections."
826
- )
827
- compartment_sets = self.circuit_access.config.get_compartment_sets()
828
- write_compartment_report(
829
- report_name=report_name,
830
- output_path=output_path,
831
- cells=self.cells,
832
- report_cfg=report_cfg,
833
- source_sets=compartment_sets,
834
- source_type="compartment_set",
835
- sim_dt=self.dt,
836
- )
837
-
838
- else:
839
- node_sets = self.circuit_access.config.get_node_sets()
840
- if report_cfg.get("compartments") not in ("center", "all"):
841
- raise ValueError(
842
- f"Unsupported 'compartments' value '{report_cfg.get('compartments')}' "
843
- "for node-based section recording (must be 'center' or 'all')."
844
- )
845
- write_compartment_report(
846
- report_name=report_name,
847
- output_path=output_path,
848
- cells=self.cells,
849
- report_cfg=report_cfg,
850
- source_sets=node_sets,
851
- source_type="node_set",
852
- sim_dt=self.dt,
853
- )
854
-
855
- self.write_spike_report()
856
-
857
- def write_spike_report(self):
858
- """Collect and write in-memory recorded spike times to a SONATA HDF5
859
- file, grouped by population as required by the SONATA specification."""
860
- output_path = self.circuit_access.config.spikes_file_path
861
-
862
- if os.path.exists(output_path):
863
- os.remove(output_path)
864
-
865
- # Group spikes per population
866
- spikes_by_population = defaultdict(dict)
867
- for gid, cell in self.cells.items():
868
- pop = getattr(gid, 'population_name', None)
869
- if pop is None:
870
- continue
871
- try:
872
- cell_spikes = cell.get_recorded_spikes(location=self.spike_location, threshold=self.spike_threshold)
873
- if cell_spikes is not None:
874
- spikes_by_population[pop][gid.id] = list(cell_spikes)
875
- except AttributeError:
876
- continue
877
-
878
- # Ensure we at least create empty groups for all known populations
879
- all_populations = set(getattr(gid, 'population_name', None) for gid in self.cells.keys())
880
-
881
- for pop in all_populations:
882
- spikes = spikes_by_population.get(pop, {}) # May be empty
883
- write_sonata_spikes(output_path, spikes, pop)
File without changes
@@ -0,0 +1,78 @@
1
+ # Copyright 2025 Open Brain Institute
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Optional, Dict
16
+ from bluecellulab.reports.writers import get_writer
17
+ from bluecellulab.reports.utils import extract_spikes_from_cells # helper you already have / write
18
+
19
+
20
+ class ReportManager:
21
+ """Orchestrates writing all requested SONATA reports."""
22
+
23
+ def __init__(self, config, sim_dt: float):
24
+ self.cfg = config
25
+ self.dt = sim_dt
26
+
27
+ def write_all(
28
+ self,
29
+ cells_or_traces: Dict,
30
+ spikes_by_pop: Optional[Dict[str, Dict[int, list]]] = None,
31
+ ):
32
+ """Write all configured reports (compartment and spike) in SONATA
33
+ format.
34
+
35
+ Parameters
36
+ ----------
37
+ cells_or_traces : dict
38
+ A dictionary mapping (population, gid) to either:
39
+ - Cell objects with recorded data (used in single-process simulations), or
40
+ - Precomputed trace dictionaries, e.g., {"voltage": ndarray}, typically gathered across ranks in parallel runs.
41
+
42
+ spikes_by_pop : dict, optional
43
+ A precomputed dictionary of spike times by population.
44
+ If not provided, spike times are extracted from `cells_or_traces`.
45
+
46
+ Notes
47
+ -----
48
+ In parallel simulations, you must gather all traces and spikes to rank 0 and pass them here.
49
+ """
50
+ self._write_voltage_reports(cells_or_traces)
51
+ self._write_spike_report(spikes_by_pop or extract_spikes_from_cells(cells_or_traces, location=self.cfg.spike_location, threshold=self.cfg.spike_threshold))
52
+
53
+ def _write_voltage_reports(self, cells_or_traces):
54
+ for name, rcfg in self.cfg.get_report_entries().items():
55
+ if rcfg.get("type") != "compartment":
56
+ continue
57
+
58
+ section = rcfg.get("sections")
59
+ if section == "compartment_set":
60
+ if rcfg.get("cells") is not None:
61
+ raise ValueError("'cells' may not be set with 'compartment_set'")
62
+ src_sets, src_type = self.cfg.get_compartment_sets(), "compartment_set"
63
+ else:
64
+ if rcfg.get("compartments") not in ("center", "all"):
65
+ raise ValueError("invalid 'compartments' value")
66
+ src_sets, src_type = self.cfg.get_node_sets(), "node_set"
67
+
68
+ rcfg["_source_sets"] = src_sets
69
+ rcfg["_source_type"] = src_type
70
+
71
+ out_path = self.cfg.report_file_path(rcfg, name)
72
+ writer = get_writer("compartment")(rcfg, out_path, self.dt)
73
+ writer.write(cells_or_traces, self.cfg.tstart)
74
+
75
+ def _write_spike_report(self, spikes_by_pop):
76
+ out_path = self.cfg.spikes_file_path
77
+ writer = get_writer("spikes")({}, out_path, self.dt)
78
+ writer.write(spikes_by_pop)
@@ -0,0 +1,156 @@
1
+ # Copyright 2025 Open Brain Institute
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Report class of bluecellulab."""
15
+
16
+ from collections import defaultdict
17
+ import logging
18
+ from typing import Dict, Any, List
19
+
20
+ from bluecellulab.tools import resolve_segments, resolve_source_nodes
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _configure_recording(cell, report_cfg, source, source_type, report_name):
26
+ """Configure recording of a variable on a single cell.
27
+
28
+ This function sets up the recording of the specified variable (e.g., membrane voltage)
29
+ in the target cell, for each resolved segment.
30
+
31
+ Parameters
32
+ ----------
33
+ cell : Any
34
+ The cell object on which to configure recordings.
35
+
36
+ report_cfg : dict
37
+ The configuration dictionary for this report.
38
+
39
+ source : dict
40
+ The source definition specifying nodes or compartments.
41
+
42
+ source_type : str
43
+ Either "node_set" or "compartment_set".
44
+
45
+ report_name : str
46
+ The name of the report (used in logging).
47
+ """
48
+ variable = report_cfg.get("variable_name", "v")
49
+
50
+ node_id = cell.cell_id
51
+ compartment_nodes = source.get("compartment_set") if source_type == "compartment_set" else None
52
+
53
+ targets = resolve_segments(cell, report_cfg, node_id, compartment_nodes, source_type)
54
+ for sec, sec_name, seg in targets:
55
+ try:
56
+ cell.add_variable_recording(variable=variable, section=sec, segx=seg)
57
+ except AttributeError:
58
+ logger.warning(f"Recording for variable '{variable}' is not implemented in Cell.")
59
+ return
60
+ except Exception as e:
61
+ logger.warning(
62
+ f"Failed to record '{variable}' at {sec_name}({seg}) on GID {node_id} for report '{report_name}': {e}"
63
+ )
64
+
65
+
66
+ def configure_all_reports(cells, simulation_config):
67
+ """Configure recordings for all reports defined in the simulation
68
+ configuration.
69
+
70
+ This iterates through all report entries, resolves source nodes or compartments,
71
+ and configures the corresponding recordings on each cell.
72
+
73
+ Parameters
74
+ ----------
75
+ cells : dict
76
+ Mapping from (population, gid) → Cell object.
77
+
78
+ simulation_config : Any
79
+ Simulation configuration object providing report entries,
80
+ node sets, and compartment sets.
81
+ """
82
+ report_entries = simulation_config.get_report_entries()
83
+
84
+ for report_name, report_cfg in report_entries.items():
85
+ report_type = report_cfg.get("type", "compartment")
86
+ section = report_cfg.get("sections", "soma")
87
+
88
+ if report_type != "compartment":
89
+ raise NotImplementedError(f"Report type '{report_type}' is not supported.")
90
+
91
+ if section == "compartment_set":
92
+ source_type = "compartment_set"
93
+ source_sets = simulation_config.get_compartment_sets()
94
+ source_name = report_cfg.get("compartments")
95
+ if not source_name:
96
+ logger.warning(f"Report '{report_name}' does not specify a node set in 'compartments' for {source_type}.")
97
+ continue
98
+ else:
99
+ source_type = "node_set"
100
+ source_sets = simulation_config.get_node_sets()
101
+ source_name = report_cfg.get("cells")
102
+ if not source_name:
103
+ logger.warning(f"Report '{report_name}' does not specify a node set in 'cells' for {source_type}.")
104
+ continue
105
+
106
+ source = source_sets.get(source_name)
107
+ if not source:
108
+ logger.warning(f"{source_type.title()} '{source_name}' not found for report '{report_name}', skipping recording.")
109
+ continue
110
+
111
+ population = source["population"]
112
+ node_ids, _ = resolve_source_nodes(source, source_type, cells, population)
113
+
114
+ for node_id in node_ids:
115
+ cell = cells.get((population, node_id))
116
+ if not cell:
117
+ continue
118
+ _configure_recording(cell, report_cfg, source, source_type, report_name)
119
+
120
+
121
+ def extract_spikes_from_cells(
122
+ cells: Dict[Any, Any],
123
+ location: str = "soma",
124
+ threshold: float = -20.0,
125
+ ) -> Dict[str, Dict[int, list]]:
126
+ """Extract spike times from recorded cells, grouped by population.
127
+
128
+ Parameters
129
+ ----------
130
+ cells : dict
131
+ Mapping from (population, gid) → Cell object, or similar.
132
+
133
+ location : str
134
+ Recording location passed to Cell.get_recorded_spikes().
135
+
136
+ threshold : float
137
+ Voltage threshold (mV) used for spike detection.
138
+
139
+ Returns
140
+ -------
141
+ spikes_by_population : dict
142
+ {population → {gid_int → [spike_times_ms]}}
143
+ """
144
+ spikes_by_pop: defaultdict[str, Dict[int, List[float]]] = defaultdict(dict)
145
+
146
+ for key, cell in cells.items():
147
+ if isinstance(key, tuple):
148
+ pop, gid = key
149
+ else:
150
+ raise ValueError(f"Cell key {key} is not a (population, gid) tuple.")
151
+
152
+ times = cell.get_recorded_spikes(location=location, threshold=threshold)
153
+ if times is not None and len(times) > 0:
154
+ spikes_by_pop[pop][gid] = list(times)
155
+
156
+ return dict(spikes_by_pop)
@@ -0,0 +1,25 @@
1
+ # Copyright 2025 Open Brain Institute
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from .compartment import CompartmentReportWriter
16
+ from .spikes import SpikeReportWriter
17
+
18
+ REGISTRY = {
19
+ "compartment": CompartmentReportWriter,
20
+ "spikes": SpikeReportWriter,
21
+ }
22
+
23
+
24
+ def get_writer(report_type):
25
+ return REGISTRY[report_type]