From 433c77f6c6da3b25c10996847b788d0bca871b4d Mon Sep 17 00:00:00 2001 From: freeman-1984-coder <219325749+freeman-1984-coder@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:43:01 +0900 Subject: [PATCH 1/3] Prespecify full-brain odor gain and side-order pilot --- docs/odor-calibration-pilot.md | 79 +++++++ docs/synaptic-dynamics.md | 4 + scripts/calibrate_fullbrain_odor.py | 284 ++++++++++++++++++++++++ tests/test_odor_calibration_protocol.py | 97 ++++++++ 4 files changed, 464 insertions(+) create mode 100644 docs/odor-calibration-pilot.md create mode 100644 scripts/calibrate_fullbrain_odor.py create mode 100644 tests/test_odor_calibration_protocol.py diff --git a/docs/odor-calibration-pilot.md b/docs/odor-calibration-pilot.md new file mode 100644 index 0000000..44691b9 --- /dev/null +++ b/docs/odor-calibration-pilot.md @@ -0,0 +1,79 @@ +# Full-brain odor gain pilot: protocol before results + +This protocol tests whether changing recurrent contact strength alters transient +side responses and post-stimulus spiking in the experimental synaptic engine. +It does **not** test banana identification, flight, learning or food seeking. +There is no environment, fitted action decoder or target position in this pilot. +The source commit should be published before the GPU run; publish every result, +including non-responsive, persistently active or failed conditions. + +## Why this experiment + +The [previous full-brain odor experiment](validation/flywire-synaptic-odor-a16.md) +showed a cumulative left DNa02 bias under either stimulus side. Its time series +also contains an early right DNa02 response to right input. The +[post-stimulus counts](validation/synaptic-post-stimulus.json) show actual new +downstream spikes during the observed recovery window. Cumulative counts alone +hide timing; an exponentially smoothed rate alone cannot prove continued firing. + +## Fixed design + +- All 139,255 FlyWire v783 neurons and all 16,847,997 source aggregate edges. + The official source files and annotation mapping must pass their pinned checksums. + No edge pruning, incoming normalization, added background drive or rescue reflex. +- Four contact strengths: **0.05, 0.10, 0.175, 0.275 mV/contact**. Excitatory and + inhibitory contacts scale together. These are exploratory values, not measured + biological parameters. All other [synaptic settings](synaptic-dynamics.md) stay fixed. +- Each strength uses both **left → right** and **right → left** input order, + making eight runs. JSON state is restored only between runs. +- Each run: 50 ms silent baseline → 150 ms first odor → 200 ms recovery → + 150 ms other-side odor → 250 ms recovery. No state reset between these phases. +- Fixed `dt = 0.1 ms`, seed `20260914`, NumPy PCG64. Both sides' random draws are + consumed at every tick, even if their input is disabled, preserving matched + per-neuron input sequences across gains and orders. Left/right populations have + different sizes and independently sampled events; they are not identical copies. +- Only the pinned 35 left and 33 right DM1 ORNs receive input. Bernoulli probability + is `150 Hz × dt / 1000`; each event supplies a 68.75 mV input jump. This jump + **does not scale with recurrent contact strength**. The physiological accuracy + of this single-channel odor adapter has not been established. + +## Prespecified measurements + +Each phase records actual per-neuron spike totals, first-50-ms counts, group totals, +and group spike counts in its last 100 ms (the initial rest is only 50 ms). +An additional 10 ms trace records smoothed mean rates for visualization. + +For each stimulus, report DNa02 **ipsilateral minus contralateral** firing rate +separately for the first 50 ms and the remaining 100 ms, plus cumulative +right-minus-left spike count. The pinned mapping contains one DNa02 cell per side. +Positive ipsilateral difference is descriptive; it is not an established action +label. Compare the same side when presented first versus second. + +For each recovery, report **new spikes** in its final 100 ms in DM1 ORNs, ALPNs, +MBONs, descending neurons and DNa02. A quiet finite window does not establish +long-term stability; persistent activity alone does not establish memory. + +Report all four strengths and both orders without choosing a winner automatically. +A candidate from this one-seed pilot requires independently specified multiple-seed +and held-out stimulus validation before changing a public behavioral preset. +Changing the gain must not be described as restoring validated biological behavior. + +## Reproduce on an actual GPU + +Install the repository with dataset support, pytest and a compatible CuPy CUDA +installation, and download the same pinned data files used by the +[full-brain odor experiment](validation/flywire-synaptic-odor-a16.md). + +```sh +python scripts/validate_synaptic_cuda.py --output results/hardware-gate.json +python scripts/calibrate_fullbrain_odor.py \ + --data-dir data --annotations data/annotations.tsv \ + --output results/odor-gain-pilot.json +FLYBRAIN_REQUIRE_CUDA=1 python -m pytest -q +``` + +The hardware gate must report five executed passing GPU cases and zero skips. +There is no CPU fallback in this experiment. Retain the full output JSON, hardware +report, test results, source hashes and dependency versions. Incomplete JSON is +marked `running` or `failed`; only all eight finished runs may be `completed`. +The ordinary CPU SDK remains usable without CUDA. diff --git a/docs/synaptic-dynamics.md b/docs/synaptic-dynamics.md index d8cf5db..26625a9 100644 --- a/docs/synaptic-dynamics.md +++ b/docs/synaptic-dynamics.md @@ -11,6 +11,10 @@ it is not yet exposed by `FlyBrain.load()` or a model catalog entry. It requires explicit millivolt weights and rejects dimensionless weight units. The original benchmark is retained as a reproducible historical numerical test. +The [prespecified odor gain pilot](odor-calibration-pilot.md) compares four contact +strengths and both stimulus orders to separate early side responses from sustained +bias and recovery spiking. Its protocol is not a completed hardware result. + ## Model and source Nominal equations and parameters follow [Shiu et al., Nature (2024)](https://www.nature.com/articles/s41586-024-07763-9) diff --git a/scripts/calibrate_fullbrain_odor.py b/scripts/calibrate_fullbrain_odor.py new file mode 100644 index 0000000..0bb18b9 --- /dev/null +++ b/scripts/calibrate_fullbrain_odor.py @@ -0,0 +1,284 @@ +"""Prespecified full-graph CUDA pilot: recurrent gain, side changes, recovery. + +Four contact scales and both side orders are always reported. No food world, +decoder fitting, pruning or successful-run selection. This one-seed pilot can +identify candidates for later independent validation, not validate physiology. +""" + +import argparse +import gc +import json +import time +from pathlib import Path + +import numpy as np +from build_olfactory_map import build +from probe_synaptic_odor import input_events +from validate_fullbrain import digest, load_fullbrain + +from flybrain.config import finite_number +from flybrain.experimental.synaptic import SynapticLIFConfig +from flybrain.experimental.synaptic_cuda import SynapticCUDA +from flybrain.neurons import NeuronIndex + +CONTACT_SCALES_MV = (0.05, 0.10, 0.175, 0.275) +ORDERS = (("left", "right"), ("right", "left")) +PHASES = ( + ("rest", 50), + ("first", 150), + ("recovery_first", 200), + ("second", 150), + ("recovery_second", 250), +) + + +def phase_schedule(dt_ms, order): + dt_ms = finite_number(dt_ms, "dt_ms") + if dt_ms <= 0: + raise ValueError("dt_ms must be positive") + if tuple(order) not in ORDERS: + raise ValueError("expected both left/right orders") + tick = 0 + result = [] + for name, ms in PHASES: + steps = round(ms / dt_ms) + if steps <= 0 or not np.isclose(steps * dt_ms, ms, atol=1e-12, rtol=0): + raise ValueError("phase durations must be exact multiples of dt_ms") + side = order[0] if name == "first" else order[1] if name == "second" else None + result.append( + { + "name": name, + "side": side, + "start_tick": tick, + "end_tick": tick + steps, + "duration_ms": ms, + } + ) + tick += steps + return result + + +def summarize_run(run): + """Separate transient side responses from cumulative bias and new tail spikes. + + DNa02 has exactly one annotated cell on each side in this pinned mapping. + Recovery counts are observed spikes, never the exponentially smoothed rate. + These descriptive metrics do not select a gain or certify navigation. + """ + summary = {"stimuli": [], "recovery": []} + for phase in run["phases"]: + if phase["side"] is not None: + side = phase["side"] + other = "right" if side == "left" else "left" + onset = phase["first_50ms_spikes"] + total = phase["spikes"] + ipsi, contra = f"DNa02_{side}", f"DNa02_{other}" + later_ms = phase["duration_ms"] - 50 + summary["stimuli"].append( + { + "phase": phase["name"], + "side": side, + "onset_ipsilateral_minus_contralateral_hz": (onset[ipsi] - onset[contra]) + * 1000 + / 50, + "later_ipsilateral_minus_contralateral_hz": ( + (total[ipsi] - onset[ipsi]) - (total[contra] - onset[contra]) + ) + * 1000 + / later_ms, + "total_right_minus_left_spikes": total["DNa02_right"] - total["DNa02_left"], + } + ) + elif phase["name"].startswith("recovery"): + summary["recovery"].append( + { + "phase": phase["name"], + "tail_window_ms": phase["tail_window_ms"], + "new_tail_spikes": phase["last_100ms_spikes"], + } + ) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data-dir", type=Path, required=True) + parser.add_argument("--annotations", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + config, seed = SynapticLIFConfig(), 20260914 + root = Path(__file__).resolve().parents[1] + report = { + "schema": "flybrain-odor-gain-pilot-v1", + "status": "running", + "started_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "config": config.to_dict(), + "numpy": np.__version__, + "source_sha256": { + str(p.relative_to(root)): digest(p) + for p in ( + Path(__file__).resolve(), + root / "scripts/build_olfactory_map.py", + root / "scripts/probe_synaptic_odor.py", + root / "scripts/validate_fullbrain.py", + *sorted((root / "src/flybrain/experimental").glob("*.py")), + ) + }, + "protocol": { + "contact_scales_mv": list(CONTACT_SCALES_MV), + "orders": ORDERS, + "seed": seed, + "rng": "NumPy PCG64", + "input_rate_hz": 150, + "input_jump_mv": 68.75, + "background_input": 0, + "sensory_model": "Bernoulli p=rate*dt; DM1 single-channel approximation", + "input_jump_independent_of_contact_scale": True, + "graph_policy": "every official neuron and aggregate edge retained", + "state_policy": "reset only between runs; never reset between phases", + "scope": "one-seed pilot; no fitted decoder, behavior or biological validation", + "schedule": phase_schedule(config.dt_ms, ORDERS[0]), + "analysis": ( + "onset 50 ms and later ipsi-minus-contra DNa02 Hz; new recovery tail spikes" + ), + "selection_policy": "report all eight runs; no automatic gain or decoder selection", + }, + "models": [], + "runs": [], + } + args.output.parent.mkdir(parents=True, exist_ok=True) + + def save(): + temp = args.output.with_suffix(".partial.json") + temp.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n") + temp.replace(args.output) + + save() + try: + mapping = build(args.annotations, args.data_dir / "proofread_root_ids_783.npy") + report["mapping"] = mapping + report["mapping_input_units_note"] = ( + "Protocol defines actual mV inputs, not old map labels." + ) + input_ids = mapping["groups"]["ORN_DM1_left"] + mapping["groups"]["ORN_DM1_right"] + left_n, right_n = (len(mapping["groups"][f"ORN_DM1_{s}"]) for s in ("left", "right")) + for scale in CONTACT_SCALES_MV: + model, provenance = load_fullbrain(args.data_dir, contact_mv=scale) + report["models"].append(provenance) + index = NeuronIndex(model) + inputs = np.asarray(index.resolve(input_ids), dtype=np.int64) + groups = {k: index.resolve(v) for k, v in mapping["groups"].items()} + selected = tuple(sorted({i for values in groups.values() for i in values})) + offsets = {i: j for j, i in enumerate(selected)} + positions = {k: [offsets[i] for i in values] for k, values in groups.items()} + brain = SynapticCUDA(model, config, input_ids=input_ids, weight_units="mV") + cp = brain._cp + props = cp.cuda.runtime.getDeviceProperties(brain._device.id) + report["device"] = { + "name": props["name"].decode(), + "memory_bytes": props["totalGlobalMem"], + "cupy": cp.__version__, + "driver": cp.cuda.runtime.driverGetVersion(), + "runtime": cp.cuda.runtime.runtimeGetVersion(), + } + rest = json.loads(json.dumps(brain.snapshot(), allow_nan=False)) + jumps = np.zeros(brain.n) + for order in ORDERS: + brain.restore(rest) + rng = np.random.default_rng(seed) + run = { + "contact_mv": scale, + "order": list(order), + "graph_sha256": provenance["graph_arrays_sha256"], + "selected_neuron_ids": [model.neuron_ids[i] for i in selected], + "phases": [], + "trace": [], + } + started = time.perf_counter() + for phase in phase_schedule(config.dt_ms, order): + totals = np.zeros(len(selected), dtype=np.int64) + onset = np.zeros(len(selected), dtype=np.int64) + tail = np.zeros(len(selected), dtype=np.int64) + input_count = np.zeros(2, dtype=np.int64) + for tick in range(phase["start_tick"], phase["end_tick"]): + events = input_events( + rng, + left_n, + right_n, + 150 * config.dt_ms / 1000, + phase["side"] == "left", + phase["side"] == "right", + ) + jumps[inputs] = events * 68.75 + input_count += [int(events[:left_n].sum()), int(events[left_n:].sum())] + brain.step(jumps) + spikes = np.asarray( + brain.observe_selected(selected, ("spikes",))["spikes"], dtype=np.int64 + ) + totals += spikes + if tick - phase["start_tick"] < round(50 / config.dt_ms): + onset += spikes + if tick >= phase["end_tick"] - round(100 / config.dt_ms): + tail += spikes + if (tick + 1) % 100 == 0: + rates = np.asarray( + brain.observe_selected(selected, ("rates_hz",))["rates_hz"] + ) + run["trace"].append( + { + "time_ms": (tick + 1) * config.dt_ms, + "phase": phase["name"], + "mean_rates_hz": { + k: float(rates[pos].mean()) for k, pos in positions.items() + }, + } + ) + item = { + **phase, + "input_events": input_count.tolist(), + "spikes": {k: int(totals[pos].sum()) for k, pos in positions.items()}, + "first_50ms_spikes": { + k: int(onset[pos].sum()) for k, pos in positions.items() + }, + "last_100ms_spikes": { + k: int(tail[pos].sum()) for k, pos in positions.items() + }, + "tail_window_ms": min(100, phase["duration_ms"]), + "per_neuron_spikes": totals.tolist(), + "first_50ms_per_neuron_spikes": onset.tolist(), + } + if phase["name"] == "rest" and totals.any(): + raise AssertionError("fresh no-input baseline must remain silent") + run["phases"].append(item) + run["wall_seconds"] = time.perf_counter() - started + run["summary"] = summarize_run(run) + report["runs"].append(run) + save() + print( + json.dumps( + { + "contact_mv": scale, + "order": order, + "wall_seconds": run["wall_seconds"], + "spikes": {p["name"]: p["spikes"] for p in run["phases"]}, + } + ), + flush=True, + ) + brain._stream.synchronize() + del brain, model, rest, index + gc.collect() + cp.get_default_memory_pool().free_all_blocks() + if len(report["runs"]) != 8: + raise AssertionError("all eight prespecified runs must complete") + report["status"] = "completed" + except Exception as error: + report.update(status="failed", error=f"{type(error).__name__}: {error}") + raise + finally: + report["finished_utc"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + save() + + +if __name__ == "__main__": + main() diff --git a/tests/test_odor_calibration_protocol.py b/tests/test_odor_calibration_protocol.py new file mode 100644 index 0000000..444918a --- /dev/null +++ b/tests/test_odor_calibration_protocol.py @@ -0,0 +1,97 @@ +"""Timing and analyses must distinguish a transient response from persistent bias.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def protocol(): + scripts = Path(__file__).parents[1] / "scripts" + spec = importlib.util.spec_from_file_location( + "odor_calibration", scripts / "calibrate_fullbrain_odor.py" + ) + module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str(scripts)) + try: + spec.loader.exec_module(module) + finally: + sys.path.pop(0) + return module + + +def test_counterbalanced_schedule_keeps_same_clock_and_unstimulated_recovery(): + p = protocol() + for order in p.ORDERS: + phases = p.phase_schedule(0.1, order) + assert [(s["start_tick"], s["end_tick"]) for s in phases] == [ + (0, 500), + (500, 2000), + (2000, 4000), + (4000, 5500), + (5500, 8000), + ] + assert [s["side"] for s in phases] == [None, order[0], None, order[1], None] + assert sum(s["duration_ms"] for s in phases) == 800 + + +@pytest.mark.parametrize("dt", [0, -0.1, float("nan"), float("inf"), True, 0.3]) +def test_invalid_timestep_cannot_silently_change_the_protocol(dt): + with pytest.raises(ValueError): + protocol().phase_schedule(dt, ("left", "right")) + + +def test_duplicate_side_cannot_be_mistaken_for_counterbalancing(): + with pytest.raises(ValueError): + protocol().phase_schedule(0.1, ("left", "left")) + + +def test_summary_preserves_early_right_response_despite_later_left_bias(): + run = { + "phases": [ + { + "name": "first", + "side": "right", + "duration_ms": 150, + "first_50ms_spikes": {"DNa02_left": 1, "DNa02_right": 2}, + "spikes": {"DNa02_left": 6, "DNa02_right": 3}, + }, + { + "name": "recovery_first", + "side": None, + "duration_ms": 200, + "tail_window_ms": 100, + "last_100ms_spikes": {"DNa02_left": 0, "DNa02_right": 0}, + # A decaying rate estimate alone must not imply continued spikes. + "mean_rates_hz": {"DNa02_left": 8.0, "DNa02_right": 4.0}, + }, + ] + } + result = protocol().summarize_run(run) + assert result["stimuli"][0] == { + "phase": "first", + "side": "right", + "onset_ipsilateral_minus_contralateral_hz": 20.0, + "later_ipsilateral_minus_contralateral_hz": -40.0, + "total_right_minus_left_spikes": -3, + } + assert result["recovery"][0]["new_tail_spikes"] == {"DNa02_left": 0, "DNa02_right": 0} + + +def test_ipsilateral_metric_changes_reference_with_stimulated_side(): + result = protocol().summarize_run( + { + "phases": [ + { + "name": "second", + "side": "left", + "duration_ms": 150, + "first_50ms_spikes": {"DNa02_left": 2, "DNa02_right": 1}, + "spikes": {"DNa02_left": 3, "DNa02_right": 6}, + } + ] + } + ) + assert result["stimuli"][0]["onset_ipsilateral_minus_contralateral_hz"] == 20 + assert result["stimuli"][0]["later_ipsilateral_minus_contralateral_hz"] == -40 From dde738ab1efc23299ad1d518358602bd50028117 Mon Sep 17 00:00:00 2001 From: freeman-1984-coder <219325749+freeman-1984-coder@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:48:39 +0900 Subject: [PATCH 2/3] Explain odor input provenance and distinguish the two GPU presets --- docs/olfactory-experiment.md | 34 +++++++++++++++++++++++++++++----- site/foraging.html | 6 ++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/olfactory-experiment.md b/docs/olfactory-experiment.md index a5b3f98..2cc32f5 100644 --- a/docs/olfactory-experiment.md +++ b/docs/olfactory-experiment.md @@ -1,7 +1,12 @@ # Full-brain food-odor experiment -**GPU probe completed; navigation not demonstrated.** -See the [negative result and parameter diagnosis](validation/flywire-odor-a16.md). The full FlyWire graph has +**Two GPU probes completed with different dynamics; navigation not demonstrated.** +The historical dimensionless benchmark could not propagate sensory-only input; +see its [negative result and parameter diagnosis](validation/flywire-odor-a16.md). +The separate [synaptic mV probe](validation/flywire-synaptic-odor-a16.md) did produce +ALPN, MBON and descending spikes. Its [voxel recording](validation/flywire-voxel-a16.md) +shows movement but no food contact. Do not transfer a result between these presets. +The full FlyWire graph has passed the [CUDA numerical benchmark](fullbrain-validation.md). This experiment adds anatomically identified sensory input. It does not establish that the fly recognizes a banana, seeks food, or flies. No CUDA is needed to build the mapping, @@ -32,7 +37,7 @@ member participates in this particular odor response. ## Reproduce the mapping and GPU probe -Start with the CUDA development branch and the full-brain data/environment +Use a pinned repository commit containing the CUDA engines and the full-brain data/environment instructions in [fullbrain-validation.md](fullbrain-validation.md). Download the annotation file into your experiment folder, outside the repository: @@ -61,6 +66,10 @@ separate source/license metadata in the full-brain data instructions. ## Sensory and world assumptions +The following `OlfactoryDrive` adapter and `probe_fullbrain_odor.py` command describe +the **historical dimensionless engine**. They are not the mV input used in the +newer GPU recording. + `flybrain.olfaction.OlfactoryDrive` takes two local antenna concentration samples. It injects only the selected sensory neurons. It receives no banana coordinates, target bearing, desired turn or reward. Its stateless response is @@ -71,15 +80,30 @@ channels can be added explicitly by creating additional adapters and summing their vectors. There is intentionally no scientifically unqualified `banana` preset. +For the **synaptic mV voxel experiment**, each antenna's dimensionless local sample +is instead mapped to `rate_hz = 180 * concentration / (0.2 + concentration)`. +At every 0.1 ms tick, each selected ORN independently receives an input event with +probability `rate_hz * 0.1 / 1000`. Each event adds 68.75 mV to that input cell's +membrane voltage under the [engine's documented update order](synaptic-dynamics.md). +The 150 Hz fixed-input odor probe uses the same event mechanism. These rates and +voltage jumps approximate receptor transduction; they are not measured banana +dose-response curves. The generated events enter only the 68 selected ORNs, +while all 139,255 neurons and source edges participate in the simulation. + `sample_odor(antenna_xyz, source_xyz)` is a game-side isotropic Gaussian field. Sample it separately at each antenna. It models neither turbulent plumes nor obstacle-induced airflow. The body/world owns spatial coordinates; the neural -controller receives concentrations only. The initial world will be a kinematic +controller receives concentrations only. The recorded world uses a kinematic body in a voxel scene, not a reconstruction of flight musculature or a full ventral nerve cord. FlyWire v783 is a whole-brain dataset, not the whole animal's nervous system. -## Falsifiable first gate +## Falsifiable first gate and next calibration + +The five-condition gate below was run for both engines. The +[prespecified gain pilot](odor-calibration-pilot.md) now asks how recurrent strength +affects transient side responses, later bias and recovery, before selecting a +behavioral preset. Its design is separate from the completed five-condition results. Five conditions reset to the identical resting checkpoint: no odor, left odor, right odor, bilateral odor, and bilateral odor with the 68 ORNs silenced. Each diff --git a/site/foraging.html b/site/foraging.html index dff590c..c0223b0 100644 --- a/site/foraging.html +++ b/site/foraging.html @@ -23,5 +23,11 @@
等待实际记录。
比较正常神经输入和关闭嗅觉入口的同初始状态对照。轨迹来自实际记录;网页不会让角色自动朝向香蕉。
结果以记录为准,包括没有接近或接触食物的情况。
全部脑神经元和连接来自 FlyWire v783。输入只覆盖一个 DM1 食物气味通道,不代表完整香蕉气味或视觉识别。脑模型采用简化的突触 LIF 动力学。
动作层是未经训练的工程映射:DNa02 两侧放电频率之差控制转向、频率之和控制速度。尤其是速度映射没有生物学验证。身体是地面运动模型,没有飞行、肌肉或六足步态仿真。记录中的移动不等于可靠觅食。
香蕉图标在这里标记气味源的位置。我们没有让模型识别画面中的香蕉,也还没有重建香蕉的多种挥发物混合气味。
+当前输入依据是:乙酸乙酯可以激活 Or42b 嗅觉通路,相关神经元投射到 DM1。我们根据固定版本的 FlyWire 细胞注释,选择左侧 35 个、右侧 33 个 ORN_DM1 神经元;它们只是入口,全脑的 139,255 个神经元仍参与计算。
+环境分别计算左右触角处的局部浓度,转换成输入脉冲概率,再给对应输入细胞施加膜电位跳变。浓度与脉冲频率的关系是未经实测校准的工程近似。脑子和动作读出没有直接收到香蕉的位置或目标方向。
+Sensory provenance: ethyl-acetate-responsive Or42b/DM1 input, 68 anatomically annotated ORNs within the full FlyWire graph. The concentration-to-spike adapter is an explicit approximation, not a validated banana mixture or receptor dose-response model.
+