| |
| """ |
| sim_worker.py — Standalone circuit simulator for subprocess-based parallelism. |
| |
| Called by batch_generate_v2.py via subprocess.Popen: |
| python3 sim_worker.py <config_json_path> <output_dir> <ach_model> |
| |
| Reads circuit config from JSON, builds HH network in Jaxley, sweeps 11 ACh |
| levels, computes 11 summary statistics, writes output JSON. |
| |
| ACh models: |
| v14a: Synaptic suppression only (same as v13 production data) |
| v14b: Mild depolarization only (0.0002 nA max, sigmoid onset at ach=0.3) |
| v14c: Both mechanisms combined |
| |
| Output: <output_dir>/circuit_XXXXX.json (list of 11 dicts, one per ACh level) |
| On error: <output_dir>/circuit_XXXXX.error (error message) |
| |
| Exit codes: 0 = success, 1 = failure |
| """ |
|
|
| import sys |
| import json |
| import math |
| import os |
| import time |
| import traceback |
|
|
| |
| |
| import logging |
| logging.getLogger("jaxley").setLevel(logging.ERROR) |
| logging.getLogger("jax").setLevel(logging.WARNING) |
|
|
| |
| |
| |
| ACH_LEVELS = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] |
|
|
| |
| OU_MU = -0.001 |
| OU_SIGMA = 0.001 |
| OU_TAU = 5.0 |
|
|
| |
| EXC_GS = 0.000005 |
| INH_GS = 0.00003 |
| E_SYN_EXC = 0.0 |
| E_SYN_INH = -80.0 |
|
|
|
|
| |
| |
| |
| def ach_to_mu_shift_v14a(ach: float) -> float: |
| """v14a: No depolarization (synaptic suppression only).""" |
| return 0.0 |
|
|
| def ach_to_syn_scale_v14a(ach: float) -> float: |
| """v14a: E2 exponential synaptic suppression (Ramaswamy 2018 Table 2).""" |
| return max(0.05, math.exp(-2.3 * ach)) |
|
|
| def ach_to_mu_shift_v14b(ach: float) -> float: |
| """v14b: Mild depolarization only. |
| |
| +0.0002 nA max (6× smaller than v10's 0.0012 which caused synchrony). |
| Sigmoid onset at ach=0.3 to avoid low-ACh correlation spikes. |
| """ |
| return 0.0002 / (1.0 + math.exp(-10.0 * (ach - 0.3))) |
|
|
| def ach_to_syn_scale_v14b(ach: float) -> float: |
| """v14b: No synaptic suppression (depolarization only).""" |
| return 1.0 |
|
|
| def ach_to_mu_shift_v14c(ach: float) -> float: |
| """v14c: Same depolarization as v14b.""" |
| return 0.0002 / (1.0 + math.exp(-10.0 * (ach - 0.3))) |
|
|
| def ach_to_syn_scale_v14c(ach: float) -> float: |
| """v14c: Same synaptic suppression as v14a.""" |
| return max(0.05, math.exp(-2.3 * ach)) |
|
|
|
|
| ACH_MODELS = { |
| "v14a": (ach_to_mu_shift_v14a, ach_to_syn_scale_v14a), |
| "v14b": (ach_to_mu_shift_v14b, ach_to_syn_scale_v14b), |
| "v14c": (ach_to_mu_shift_v14c, ach_to_syn_scale_v14c), |
| } |
|
|
|
|
| |
| |
| |
| def generate_ou_current(dt_ms, n_steps, mu, sigma, tau_ms, rng): |
| """Ornstein-Uhlenbeck process: colored noise for background synaptic input.""" |
| import numpy as np |
| x = np.zeros(n_steps) |
| x[0] = mu |
| dt_s = dt_ms / 1000.0 |
| tau_s = tau_ms / 1000.0 |
| noise_coeff = sigma * math.sqrt(2.0 * dt_s / tau_s) |
| for step in range(1, n_steps): |
| x[step] = x[step-1] + dt_s * (mu - x[step-1]) / tau_s + noise_coeff * rng.randn() |
| return x |
|
|
|
|
| |
| |
| |
| def compute_statistics(spike_trains, n_exc, n_inh, duration_ms): |
| """Compute population-level summary statistics from spike trains.""" |
| import numpy as np |
|
|
| n_total = n_exc + n_inh |
| duration_s = duration_ms / 1000.0 |
|
|
| |
| rates = [len(st) / duration_s for st in spike_trains] |
| exc_rates = rates[:n_exc] |
| inh_rates = rates[n_exc:] |
|
|
| mean_rate = float(np.mean(rates)) if rates else 0.0 |
| mean_exc_rate = float(np.mean(exc_rates)) if exc_rates else 0.0 |
| mean_inh_rate = float(np.mean(inh_rates)) if inh_rates else 0.0 |
|
|
| |
| cv_isis = [] |
| for st in spike_trains: |
| if len(st) > 2: |
| isis = np.diff(st) |
| if np.mean(isis) > 0: |
| cv_isis.append(float(np.std(isis) / np.mean(isis))) |
| mean_cv_isi = float(np.mean(cv_isis)) if cv_isis else 0.0 |
|
|
| |
| bin_size_ms = 50.0 |
| n_bins = int(duration_ms / bin_size_ms) |
| if n_bins > 0: |
| bin_counts = np.zeros((n_total, n_bins)) |
| for i, st in enumerate(spike_trains): |
| for t in st: |
| b = min(int(t / bin_size_ms), n_bins - 1) |
| bin_counts[i, b] += 1 |
| fano_factors = [] |
| for i in range(n_total): |
| m = np.mean(bin_counts[i]) |
| if m > 0: |
| fano_factors.append(float(np.var(bin_counts[i]) / m)) |
| mean_fano = float(np.mean(fano_factors)) if fano_factors else 1.0 |
| else: |
| mean_fano = 1.0 |
|
|
| |
| pop_bin_ms = 5.0 |
| n_pop_bins = int(duration_ms / pop_bin_ms) |
| if n_pop_bins > 0: |
| pop_rate = np.zeros(n_pop_bins) |
| for st in spike_trains: |
| for t in st: |
| b = min(int(t / pop_bin_ms), n_pop_bins - 1) |
| pop_rate[b] += 1 |
| pop_rate /= n_total |
| pop_mean = np.mean(pop_rate) |
| synchrony_index = float(np.var(pop_rate) / pop_mean) if pop_mean > 0 else 0.0 |
| else: |
| synchrony_index = 0.0 |
|
|
| |
| n_sample = min(100, n_exc * (n_exc - 1) // 2) |
| if n_sample > 0 and n_pop_bins > 0: |
| corr_bin_ms = 10.0 |
| n_corr_bins = int(duration_ms / corr_bin_ms) |
| binned = np.zeros((n_exc, n_corr_bins)) |
| for i in range(n_exc): |
| for t in spike_trains[i]: |
| b = min(int(t / corr_bin_ms), n_corr_bins - 1) |
| binned[i, b] += 1 |
| pair_rng = np.random.RandomState(0) |
| correlations = [] |
| for _ in range(n_sample): |
| i, j = pair_rng.choice(n_exc, 2, replace=False) |
| x, y = binned[i], binned[j] |
| if np.std(x) > 0 and np.std(y) > 0: |
| r = float(np.corrcoef(x, y)[0, 1]) |
| if not np.isnan(r): |
| correlations.append(r) |
| mean_pairwise_corr = float(np.mean(correlations)) if correlations else 0.0 |
| else: |
| mean_pairwise_corr = 0.0 |
|
|
| |
| if n_pop_bins > 10: |
| from scipy import signal |
| freqs, psd = signal.welch(pop_rate, fs=1000.0 / pop_bin_ms, nperseg=min(256, n_pop_bins)) |
| peak_freq = float(freqs[np.argmax(psd)]) |
| total_power = float(np.sum(psd)) |
| else: |
| peak_freq = 0.0 |
| total_power = 0.0 |
|
|
| return { |
| "mean_firing_rate": round(mean_rate, 3), |
| "mean_exc_rate": round(mean_exc_rate, 3), |
| "mean_inh_rate": round(mean_inh_rate, 3), |
| "mean_cv_isi": round(mean_cv_isi, 3), |
| "mean_fano_factor": round(mean_fano, 3), |
| "synchrony_index": round(synchrony_index, 4), |
| "mean_pairwise_corr": round(mean_pairwise_corr, 4), |
| "peak_frequency_hz": round(peak_freq, 3), |
| "total_spectral_power": round(total_power, 6), |
| "n_active_neurons": sum(1 for r in rates if r > 0.5), |
| "total_spikes": sum(len(st) for st in spike_trains), |
| } |
|
|
|
|
| |
| |
| |
| def simulate_circuit(config: dict, output_dir: str, ach_model: str): |
| """Build one circuit, sweep all 11 ACh levels, save results.""" |
| import numpy as np |
| import copy |
|
|
| |
| import jaxley as jx |
| from jaxley.channels import HH |
| from jaxley.synapses import IonotropicSynapse |
|
|
| |
| mu_shift_fn, syn_scale_fn = ACH_MODELS[ach_model] |
|
|
| circuit_id = config["circuit_id"] |
| n_exc = config["n_exc"] |
| n_inh = config["n_inh"] |
| conn_prob = config["conn_prob"] |
| sim_duration_ms = config["sim_duration_ms"] |
| seed = config["seed"] |
|
|
| n_total = n_exc + n_inh |
| rng = np.random.RandomState(seed) |
| dt = 0.025 |
| n_steps = int(sim_duration_ms / dt) |
|
|
| t_start = time.time() |
|
|
| |
| cells = [] |
| for i in range(n_total): |
| comp = jx.Compartment() |
| comp.insert(HH()) |
| cells.append(comp) |
| net = jx.Network(cells) |
|
|
| |
| adjacency = rng.random((n_total, n_total)) < conn_prob |
| np.fill_diagonal(adjacency, False) |
|
|
| syn_count = 0 |
| for pre_idx in range(n_total): |
| for post_idx in range(n_total): |
| if adjacency[pre_idx, post_idx]: |
| jx.connect(net.cell(pre_idx), net.cell(post_idx), IonotropicSynapse()) |
| syn_count += 1 |
|
|
| t_build = time.time() - t_start |
|
|
| |
| if syn_count > 0: |
| gs_arr = np.empty(syn_count) |
| esyn_arr = np.empty(syn_count) |
| syn_idx = 0 |
| for pre_idx in range(n_total): |
| for post_idx in range(n_total): |
| if adjacency[pre_idx, post_idx]: |
| if pre_idx < n_exc: |
| gs_arr[syn_idx] = EXC_GS |
| esyn_arr[syn_idx] = E_SYN_EXC |
| else: |
| gs_arr[syn_idx] = INH_GS |
| esyn_arr[syn_idx] = E_SYN_INH |
| syn_idx += 1 |
| net.edges["IonotropicSynapse_gS"] = gs_arr |
| net.edges["IonotropicSynapse_e_syn"] = esyn_arr |
| net.edges["IonotropicSynapse_s"] = 0.0 |
|
|
| mean_in_degree = max(1.0, syn_count / n_total) |
|
|
| |
| noise_seeds = rng.randint(0, 1000000, n_total) |
| stim_len = int(sim_duration_ms / dt) + 1 |
|
|
| |
| results = [] |
|
|
| for ach_idx, ach in enumerate(ACH_LEVELS): |
| t0 = time.time() |
|
|
| net_copy = copy.deepcopy(net) |
|
|
| |
| syn_scale = syn_scale_fn(ach) |
| if syn_count > 0: |
| exc_mask = net_copy.edges["IonotropicSynapse_e_syn"] == E_SYN_EXC |
| net_copy.edges.loc[exc_mask, "IonotropicSynapse_gS"] = EXC_GS * syn_scale |
|
|
| |
| mu_shift = mu_shift_fn(ach) |
| mu_eff = OU_MU + mu_shift |
| sigma_eff = OU_SIGMA |
|
|
| |
| for i in range(n_total): |
| neuron_rng = np.random.RandomState(noise_seeds[i]) |
| ou_trace = generate_ou_current(dt, n_steps, mu_eff, sigma_eff, OU_TAU, neuron_rng) |
| i_stim = np.zeros(stim_len) |
| ou_len = min(len(ou_trace), stim_len) |
| i_stim[:ou_len] = ou_trace[:ou_len] |
| net_copy.cell(i).stimulate(i_stim) |
| net_copy.cell(i).record("v") |
|
|
| voltages = jx.integrate(net_copy, delta_t=dt) |
| t_sim = time.time() - t0 |
|
|
| |
| v_np = np.array(voltages) |
| warmup_steps = int(200.0 / dt) |
| spike_trains = [] |
| for i in range(n_total): |
| v_stable = v_np[i, warmup_steps:] |
| crossings = np.where((v_stable[:-1] < 0.0) & (v_stable[1:] >= 0.0))[0] |
| spike_times_ms = ((crossings + warmup_steps) * dt).tolist() |
| spike_trains.append(spike_times_ms) |
|
|
| |
| stable_duration_ms = sim_duration_ms - 200.0 |
| stats = compute_statistics(spike_trains, n_exc, n_inh, stable_duration_ms) |
|
|
| results.append({ |
| "circuit_id": circuit_id, |
| "ach_level": ach, |
| "ach_model": ach_model, |
| "n_exc": n_exc, |
| "n_inh": n_inh, |
| "n_total": n_total, |
| "conn_prob": conn_prob, |
| "n_synapses": syn_count, |
| "mean_in_degree": round(mean_in_degree, 1), |
| "gS_exc_effective": round(EXC_GS * syn_scale, 8), |
| "ou_mu_effective": round(mu_eff, 6), |
| "ou_sigma_effective": round(sigma_eff, 6), |
| "ou_tau": OU_TAU, |
| "sim_duration_ms": sim_duration_ms, |
| "seed": seed, |
| "sim_time_s": round(t_sim, 2), |
| "statistics": stats, |
| }) |
|
|
| total_time = time.time() - t_start |
|
|
| |
| os.makedirs(output_dir, exist_ok=True) |
| outpath = os.path.join(output_dir, f"circuit_{circuit_id:05d}.json") |
| with open(outpath, "w") as f: |
| json.dump(results, f) |
|
|
| |
| print( |
| f"OK circuit={circuit_id} model={ach_model} " |
| f"build={t_build:.1f}s total={total_time:.1f}s " |
| f"rate@0={results[0]['statistics']['mean_firing_rate']:.1f}Hz " |
| f"corr@0={results[0]['statistics']['mean_pairwise_corr']:.4f} " |
| f"corr@1={results[-1]['statistics']['mean_pairwise_corr']:.4f}", |
| file=sys.stderr, |
| ) |
|
|
| return results |
|
|
|
|
| |
| |
| |
| def main(): |
| if len(sys.argv) != 4: |
| print(f"Usage: python3 {sys.argv[0]} <config_json_path> <output_dir> <ach_model>", |
| file=sys.stderr) |
| print(f" ach_model: v14a | v14b | v14c", file=sys.stderr) |
| sys.exit(1) |
|
|
| config_path = sys.argv[1] |
| output_dir = sys.argv[2] |
| ach_model = sys.argv[3] |
|
|
| if ach_model not in ACH_MODELS: |
| print(f"ERROR: Unknown ACh model '{ach_model}'. Must be one of: {list(ACH_MODELS.keys())}", |
| file=sys.stderr) |
| sys.exit(1) |
|
|
| |
| with open(config_path, "r") as f: |
| config = json.load(f) |
|
|
| circuit_id = config["circuit_id"] |
|
|
| try: |
| simulate_circuit(config, output_dir, ach_model) |
| except Exception as e: |
| |
| os.makedirs(output_dir, exist_ok=True) |
| error_path = os.path.join(output_dir, f"circuit_{circuit_id:05d}.error") |
| with open(error_path, "w") as f: |
| f.write(f"{type(e).__name__}: {e}\n") |
| f.write(traceback.format_exc()) |
| print(f"FAIL circuit={circuit_id}: {e}", file=sys.stderr) |
| sys.exit(1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|