Coverage for src/driada/experiment/synthetic/manifold_circular.py: 94.52%

73 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-25 15:40 +0300

1""" 

2Circular manifold generation for head direction cells. 

3 

4This module contains functions for generating synthetic neural data on circular 

5manifolds, typically used to model head direction cells. 

6""" 

7 

8import numpy as np 

9from .core import validate_peak_rate, generate_pseudo_calcium_signal 

10from .utils import get_effective_decay_time 

11from ..exp_base import Experiment 

12from ...information.info_base import TimeSeries, MultiTimeSeries 

13 

14 

15def generate_circular_random_walk(length, step_std=0.1, seed=None): 

16 """ 

17 Generate a random walk on a circle (head direction trajectory). 

18  

19 Parameters 

20 ---------- 

21 length : int 

22 Number of time points. 

23 step_std : float 

24 Standard deviation of angular steps in radians. 

25 seed : int, optional 

26 Random seed for reproducibility. 

27  

28 Returns 

29 ------- 

30 angles : ndarray 

31 Array of angles in radians [0, 2π). 

32 """ 

33 if seed is not None: 

34 np.random.seed(seed) 

35 

36 # Generate random steps 

37 steps = np.random.normal(0, step_std, length) 

38 

39 # Cumulative sum to get trajectory 

40 angles = np.cumsum(steps) 

41 

42 # Wrap to [0, 2π) 

43 angles = angles % (2 * np.pi) 

44 

45 return angles 

46 

47 

48def von_mises_tuning_curve(angles, preferred_direction, kappa): 

49 """ 

50 Calculate neural response using Von Mises tuning curve. 

51  

52 Parameters 

53 ---------- 

54 angles : ndarray 

55 Current head directions in radians. 

56 preferred_direction : float 

57 Preferred direction of the neuron in radians. 

58 kappa : float 

59 Concentration parameter (inverse width of tuning curve). 

60 Higher kappa = narrower tuning. 

61  

62 Returns 

63 ------- 

64 response : ndarray 

65 Neural response (firing rate modulation). 

66 """ 

67 # Von Mises distribution normalized to max=1 

68 response = np.exp(kappa * (np.cos(angles - preferred_direction) - 1)) 

69 return response 

70 

71 

72def generate_circular_manifold_neurons(n_neurons, head_direction, kappa=4.0, 

73 baseline_rate=0.1, peak_rate=1.0, 

74 noise_std=0.05, seed=None): 

75 """ 

76 Generate population of head direction cells with Von Mises tuning. 

77  

78 Parameters 

79 ---------- 

80 n_neurons : int 

81 Number of neurons in the population. 

82 head_direction : ndarray 

83 Head direction trajectory in radians. 

84 kappa : float 

85 Concentration parameter for Von Mises tuning curves. 

86 Typical values: 2-8 (higher = narrower tuning). 

87 baseline_rate : float 

88 Baseline firing rate when far from preferred direction. 

89 Default is 0.1 Hz (realistic for sparse firing neurons). 

90 peak_rate : float 

91 Peak firing rate at preferred direction. 

92 Default is 1.0 Hz (realistic for calcium imaging). 

93 Values >2 Hz may cause calcium signal saturation. 

94 noise_std : float 

95 Standard deviation of noise in firing rates. 

96 seed : int, optional 

97 Random seed for reproducibility. 

98  

99 Returns 

100 ------- 

101 firing_rates : ndarray 

102 Shape (n_neurons, n_timepoints) with firing rates. 

103 preferred_directions : ndarray 

104 Preferred direction for each neuron in radians. 

105 """ 

106 # Validate firing rate 

107 validate_peak_rate(peak_rate, context="generate_circular_manifold_neurons") 

108 

109 if seed is not None: 

110 np.random.seed(seed) 

111 

112 n_timepoints = len(head_direction) 

113 

114 # Uniformly distribute preferred directions around the circle 

115 preferred_directions = np.linspace(0, 2*np.pi, n_neurons, endpoint=False) 

116 

117 # Add small random jitter to break perfect symmetry 

118 jitter = np.random.normal(0, 0.1, n_neurons) 

119 preferred_directions = (preferred_directions + jitter) % (2*np.pi) 

120 

121 # Generate firing rates for each neuron 

122 firing_rates = np.zeros((n_neurons, n_timepoints)) 

123 

124 for i in range(n_neurons): 

125 # Von Mises tuning curve 

126 tuning_response = von_mises_tuning_curve(head_direction, 

127 preferred_directions[i], 

128 kappa) 

129 

130 # Scale to desired firing rate range 

131 firing_rate = baseline_rate + (peak_rate - baseline_rate) * tuning_response 

132 

133 # Add noise 

134 noise = np.random.normal(0, noise_std, n_timepoints) 

135 firing_rate = np.maximum(0, firing_rate + noise) # Ensure non-negative 

136 

137 firing_rates[i, :] = firing_rate 

138 

139 return firing_rates, preferred_directions 

140 

141 

142def generate_circular_manifold_data(n_neurons, duration=600, sampling_rate=20.0, 

143 kappa=4.0, step_std=0.1, 

144 baseline_rate=0.1, peak_rate=1.0, 

145 noise_std=0.05, 

146 decay_time=2.0, calcium_noise_std=0.1, 

147 seed=None, verbose=True): 

148 """ 

149 Generate synthetic data with neurons on circular manifold (head direction cells). 

150  

151 Parameters 

152 ---------- 

153 n_neurons : int 

154 Number of neurons. 

155 duration : float 

156 Duration in seconds. 

157 sampling_rate : float 

158 Sampling rate in Hz. 

159 kappa : float 

160 Von Mises concentration parameter (tuning width). 

161 step_std : float 

162 Standard deviation of head direction random walk steps. 

163 baseline_rate : float 

164 Baseline firing rate. Default is 0.1 Hz. 

165 peak_rate : float 

166 Peak firing rate at preferred direction. Default is 1.0 Hz. 

167 Values >2 Hz may cause calcium signal saturation. 

168 noise_std : float 

169 Noise in firing rates. 

170 decay_time : float 

171 Calcium decay time constant. 

172 calcium_noise_std : float 

173 Noise in calcium signal. 

174 seed : int, optional 

175 Random seed. 

176 verbose : bool 

177 Print progress. 

178  

179 Returns 

180 ------- 

181 calcium_signals : ndarray 

182 Calcium signals (n_neurons x n_timepoints). 

183 head_direction : ndarray 

184 Head direction trajectory. 

185 preferred_directions : ndarray 

186 Preferred direction for each neuron. 

187 firing_rates : ndarray 

188 Underlying firing rates. 

189 """ 

190 if seed is not None: 

191 np.random.seed(seed) 

192 

193 n_timepoints = int(duration * sampling_rate) 

194 

195 if verbose: 

196 print(f'Generating circular manifold data: {n_neurons} neurons, {duration}s') 

197 

198 # Generate head direction trajectory 

199 if verbose: 

200 print(' Generating head direction trajectory...') 

201 head_direction = generate_circular_random_walk(n_timepoints, step_std, seed) 

202 

203 # Generate neural responses 

204 if verbose: 

205 print(' Generating neural responses with Von Mises tuning...') 

206 firing_rates, preferred_directions = generate_circular_manifold_neurons( 

207 n_neurons, head_direction, kappa, 

208 baseline_rate, peak_rate, noise_std, 

209 seed=(seed + 1) if seed else None 

210 ) 

211 

212 # Convert firing rates to calcium signals 

213 if verbose: 

214 print(' Converting to calcium signals...') 

215 calcium_signals = np.zeros((n_neurons, n_timepoints)) 

216 

217 for i in range(n_neurons): 

218 # Generate Poisson events from firing rates 

219 prob_spike = firing_rates[i, :] / sampling_rate 

220 prob_spike = np.clip(prob_spike, 0, 1) # Ensure valid probability 

221 events = np.random.binomial(1, prob_spike) 

222 

223 # Convert to calcium using existing function 

224 calcium_signal = generate_pseudo_calcium_signal( 

225 events=events, 

226 duration=duration, 

227 sampling_rate=sampling_rate, 

228 amplitude_range=(0.5, 2.0), 

229 decay_time=decay_time, 

230 noise_std=calcium_noise_std 

231 ) 

232 calcium_signals[i, :] = calcium_signal 

233 

234 if verbose: 

235 print(' Done!') 

236 

237 return calcium_signals, head_direction, preferred_directions, firing_rates 

238 

239 

240def generate_circular_manifold_exp(n_neurons=100, duration=600, fps=20.0, 

241 kappa=4.0, step_std=0.1, 

242 baseline_rate=0.1, peak_rate=1.0, 

243 noise_std=0.05, 

244 decay_time=2.0, calcium_noise_std=0.1, 

245 add_mixed_features=False, 

246 seed=None, verbose=True, return_info=False): 

247 """ 

248 Generate complete experiment with circular manifold (head direction cells). 

249  

250 Parameters 

251 ---------- 

252 n_neurons : int 

253 Number of neurons. 

254 duration : float 

255 Duration in seconds. 

256 fps : float 

257 Sampling rate (frames per second). 

258 kappa : float 

259 Von Mises concentration parameter. 

260 step_std : float 

261 Head direction random walk step size. 

262 baseline_rate : float 

263 Baseline firing rate. Default is 0.1 Hz. 

264 peak_rate : float 

265 Peak firing rate. Default is 1.0 Hz. 

266 noise_std : float 

267 Firing rate noise. 

268 decay_time : float 

269 Calcium decay time. 

270 calcium_noise_std : float 

271 Calcium signal noise. 

272 add_mixed_features : bool 

273 Whether to add circular_angle MultiTimeSeries (cos/sin representation). 

274 seed : int, optional 

275 Random seed. 

276 verbose : bool 

277 Print progress. 

278  

279 Returns 

280 ------- 

281 exp : Experiment 

282 DRIADA Experiment object with circular manifold data. 

283 """ 

284 # Calculate effective decay time for shuffle mask 

285 effective_decay_time = get_effective_decay_time(decay_time, duration, verbose) 

286 

287 # Generate data 

288 calcium, head_direction, preferred_directions, firing_rates = generate_circular_manifold_data( 

289 n_neurons=n_neurons, 

290 duration=duration, 

291 sampling_rate=fps, 

292 kappa=kappa, 

293 step_std=step_std, 

294 baseline_rate=baseline_rate, 

295 peak_rate=peak_rate, 

296 noise_std=noise_std, 

297 decay_time=decay_time, 

298 calcium_noise_std=calcium_noise_std, 

299 seed=seed, 

300 verbose=verbose 

301 ) 

302 

303 # Create static features 

304 static_features = { 

305 'fps': fps, 

306 't_rise_sec': 0.04, 

307 't_off_sec': effective_decay_time, # Use effective decay time for shuffle mask 

308 'manifold_type': 'circular', 

309 'kappa': kappa, 

310 'baseline_rate': baseline_rate, 

311 'peak_rate': peak_rate, 

312 } 

313 

314 # Create dynamic features 

315 head_direction_ts = TimeSeries( 

316 data=head_direction, 

317 discrete=False 

318 ) 

319 

320 dynamic_features = { 

321 'head_direction': head_direction_ts 

322 } 

323 

324 # Add circular_angle MultiTimeSeries if requested 

325 if add_mixed_features: 

326 # Create circular_angle as MultiTimeSeries with cos and sin components 

327 # This is the proper representation for circular variables 

328 cos_component = np.cos(head_direction) 

329 sin_component = np.sin(head_direction) 

330 circular_angle_mts = MultiTimeSeries([ 

331 TimeSeries(data=cos_component, discrete=False), 

332 TimeSeries(data=sin_component, discrete=False) 

333 ]) 

334 dynamic_features['circular_angle'] = circular_angle_mts 

335 

336 # Store additional information 

337 static_features['preferred_directions'] = preferred_directions 

338 

339 # Create experiment 

340 exp = Experiment( 

341 signature='circular_manifold_exp', 

342 calcium=calcium, 

343 spikes=None, # Will be extracted from calcium if needed 

344 static_features=static_features, 

345 dynamic_features=dynamic_features, 

346 exp_identificators={ 

347 'manifold': 'circular', 

348 'n_neurons': n_neurons, 

349 'duration': duration 

350 } 

351 ) 

352 

353 # Store firing rates as additional data 

354 exp.firing_rates = firing_rates 

355 

356 # Create info dictionary if requested 

357 if return_info: 

358 info = { 

359 'manifold_type': 'circular', 

360 'n_neurons': n_neurons, 

361 'head_direction': head_direction, 

362 'preferred_directions': preferred_directions, 

363 'firing_rates': firing_rates, 

364 'parameters': { 

365 'kappa': kappa, 

366 'step_std': step_std, 

367 'baseline_rate': baseline_rate, 

368 'peak_rate': peak_rate, 

369 'noise_std': noise_std, 

370 'decay_time': decay_time, 

371 'calcium_noise_std': calcium_noise_std 

372 } 

373 } 

374 return exp, info 

375 

376 return exp