Skip to content

Commit ee61019

Browse files
authored
fix(dyn/neurons): CondNeuGroup synaptic-current scaling (1000x attenuation) (#842)
fix(dyn/neurons): stop CondNeuGroup from attenuating synaptic current by 1e-3/A CondNeuGroup scaled incoming synaptic current by the membrane-area factor 1e-3/A (a 1000x attenuation whenever A != 1e-3); synaptic input is already a current density and must be injected unscaled via the derivative, matching CondNeuGroupLTC. (High) Findings recorded in docs/issues-found-20260619-dyn-neurons.md
1 parent 03affbf commit ee61019

3 files changed

Lines changed: 201 additions & 3 deletions

File tree

brainpy/dyn/neurons/hh.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,27 @@ def return_info(self):
182182

183183

184184
class CondNeuGroup(CondNeuGroupLTC):
185+
# density of synaptic currents evaluated at the pre-step membrane potential;
186+
# refreshed every ``update`` and defaulted here so a bare ``derivative`` call
187+
# (e.g. dynamics analysis) does not raise.
188+
_syn_current = 0.
189+
185190
def derivative(self, V, t, I):
191+
# ``I`` is the external injected current that ``CondNeuGroupLTC.update``
192+
# has already converted into a current *density* via the ``1e-3 / A``
193+
# factor. Synaptic currents are densities as well (just like channel
194+
# currents), so add the synaptic density computed at the pre-step
195+
# membrane potential *here*, NOT to the pre-scaled external input.
196+
I = I + self._syn_current
186197
for ch in self.nodes(level=1, include_self=False).subset(IonChaDyn).unique().values():
187198
I = I + ch.current(V)
188199
return I / self.C
189200

190201
def update(self, x=None):
191-
# inputs
192-
x = 0. if x is None else x
193-
x = self.sum_current_inputs(self.V.value, init=x)
202+
# Evaluate synaptic inputs at the current (pre-update) membrane potential
203+
# and stash the result as a density so it bypasses the ``1e-3 / A``
204+
# scaling applied to the external input ``x`` in the parent ``update``.
205+
self._syn_current = self.sum_current_inputs(self.V.value, init=0.)
194206
return super().update(x)
195207

196208

brainpy/dyn/neurons/hh_test.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
# ==============================================================================
16+
import numpy as np
1617
from absl.testing import parameterized
1718

1819
import brainpy as bp
@@ -152,3 +153,57 @@ def test_WangBuzsakiModelLTC_batching_mode(self):
152153
self.assertTupleEqual(runner.mon['n'].shape, (1, 100, 10))
153154
self.assertTupleEqual(runner.mon['h'].shape, (1, 100, 10))
154155
self.assertTupleEqual(runner.mon['spike'].shape, (1, 100, 10))
156+
157+
158+
class Test_CondNeuGroup_synaptic_scaling(parameterized.TestCase):
159+
"""Regression for P8-H1.
160+
161+
Synaptic currents (returned by ``current_inputs`` / ``sum_current_inputs``)
162+
are densities, exactly like channel currents. They must therefore NOT be
163+
rescaled by the ``1e-3 / A`` factor that converts the *external* injected
164+
current into a density. ``CondNeuGroupLTC`` already does this correctly;
165+
``CondNeuGroup`` used to fold the synaptic current into the pre-scaled
166+
external input, attenuating it by ``1e-3 / A`` whenever ``A != 1e-3``.
167+
"""
168+
169+
def _one_step_synaptic_dV(self, cls, A, syn_density):
170+
neu = cls(1, A=A, IL=bp.dyn.IL(1, g_max=0.0, E=-70.))
171+
neu.reset_state()
172+
# a constant synaptic current density (independent of V)
173+
neu.add_inp_fun('syn', lambda V, init=0.: init + syn_density)
174+
bp.share.save(t=0., dt=0.1, i=0)
175+
V0 = float(np.asarray(neu.V.value)[0])
176+
neu.update(0.) # no external current
177+
V1 = float(np.asarray(neu.V.value)[0])
178+
return V1 - V0
179+
180+
def test_condneugroup_synaptic_current_scaling(self):
181+
# A != 1e-3 so that the (1e-3 / A) factor is not the identity.
182+
A = 1.0
183+
syn = 10.0
184+
dt, C = 0.1, 1.0
185+
dv_ltc = self._one_step_synaptic_dV(hh.CondNeuGroupLTC, A, syn)
186+
dv_cng = self._one_step_synaptic_dV(hh.CondNeuGroup, A, syn)
187+
# Both classes must apply the synaptic current as an unscaled density.
188+
self.assertAlmostEqual(dv_ltc, dt * syn / C, places=4)
189+
self.assertAlmostEqual(dv_cng, dt * syn / C, places=4)
190+
self.assertAlmostEqual(dv_cng, dv_ltc, places=5)
191+
192+
def test_external_input_still_scaled(self):
193+
# The external injected current must STILL be scaled by 1e-3 / A
194+
# (this is the conversion from absolute current to current density).
195+
A = 1.0
196+
x = 10.0
197+
dt, C = 0.1, 1.0
198+
neu = hh.CondNeuGroup(1, A=A, IL=bp.dyn.IL(1, g_max=0.0, E=-70.),
199+
input_var=False)
200+
neu.reset_state()
201+
bp.share.save(t=0., dt=dt, i=0)
202+
V0 = float(np.asarray(neu.V.value)[0])
203+
neu.update(x)
204+
V1 = float(np.asarray(neu.V.value)[0])
205+
# the external input is converted to a density via (1e-3 / A); compare
206+
# against the first-order estimate with a loose tolerance (the actual
207+
# integrator is higher-order, so a small discrepancy is expected).
208+
expected = dt * (x * (1e-3 / A)) / C
209+
self.assertAlmostEqual((V1 - V0) / expected, 1.0, places=2)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Audit findings — `brainpy/dyn/{neurons,channels,ions}` (2026-06-19)
2+
3+
Scope: `brainpy/dyn/neurons/{base,hh,lif}.py`, `brainpy/dyn/channels/*.py`,
4+
`brainpy/dyn/ions/{base,calcium,potassium,sodium}.py` (+ co-located `*_test.py`).
5+
6+
Environment: brainpy 2.7.8, brainstate 0.5.1, brainunit 0.5.1, jax 0.10.2 (CPU).
7+
8+
## Cross-check status (vs `dev/issues-found-20260618.md`)
9+
10+
The neuron/channel entries from the prior audit are **already fixed in this worktree**:
11+
12+
- **C-14** (HH/Markov channel gating NaN at voltage singularity): FIXED. A branch-safe
13+
`_exprel` helper is present and used in `sodium.py`, `sodium_compatible.py`,
14+
`potassium.py`, `potassium_compatible.py`, and `calcium.py` (`ICaHT_Re1993`). Verified
15+
finite value *and* finite gradient at the singular voltages
16+
(`IK_HH1952v2(1).f_p_alpha([-55.0]) == 0.1`, not NaN). Regression tests already exist
17+
in `channels/dyn_channels_fixes_test.py`.
18+
- **H-33** (`ions/base.py` `add_elem(k=v)` literal-keyword bug): FIXED
19+
(`ions/base.py:55` now `self.add_elem(**{k: v})`). Regression test exists.
20+
- **H-34** (`ExpIFRef*` silently dropping `noise=`): FIXED (`lif.py:1113-1116` guards on
21+
`self.noise` and uses `sdeint`).
22+
- **H-35** (`IzhikevichRef`/`GifRef` resetting state with grad-carrying `spike`): FIXED
23+
(`lif.py:3821-3831`, `4504-4522` now use `spike_no_grad` for every state reset;
24+
`AdExIFRef`/`AdQuaIFRef` likewise).
25+
- **M-17** (`PotassiumFixed` `E` default): already `-95.` and asserted by a regression test.
26+
27+
Only one verified, still-present neuron/channel bug was found in scope (P8-H1, below),
28+
plus several Low items recorded for documentation.
29+
30+
---
31+
32+
### P8-H1 — `CondNeuGroup` scales synaptic current by `1e-3/A`, double-/mis-scaling it [High]
33+
- File: `brainpy/dyn/neurons/hh.py:148-194` (`CondNeuGroupLTC.update` + `CondNeuGroup.update`/`derivative`)
34+
- Category: correctness / numerics
35+
- What: The conductance-based neuron converts the **external injected current** `x`
36+
(an absolute current, e.g. from `bp.inputs`, in nA) into a current *density* with the
37+
factor `x = x * (1e-3 / self.A)` inside `CondNeuGroupLTC.update`. Channel currents
38+
(`ch.current(V)`, already a density in µA/cm²) are correctly left unscaled in
39+
`derivative`. In the LTC class, **synaptic** currents (`sum_current_inputs`) are summed
40+
inside `derivative` and therefore also left unscaled (correct — they are densities).
41+
But `CondNeuGroup.update` (the non-LTC default class) folds `sum_current_inputs` into
42+
`x` *before* `super().update(x)` applies the `1e-3/A` factor, so the synaptic current
43+
is multiplied by `1e-3/A`.
44+
- Why it's a bug: For any `A != 1e-3` (the default is `A=1e-3`, which masks the bug),
45+
synaptic input to a `CondNeuGroup` is silently rescaled relative to channel currents and
46+
relative to the otherwise-identical `CondNeuGroupLTC`. With `A=1.0` the synaptic drive is
47+
attenuated by 1000×.
48+
- Repro (runtime, reproduced):
49+
```python
50+
A = 1.0
51+
for cls in [bp.dyn.CondNeuGroupLTC, bp.dyn.CondNeuGroup]:
52+
neu = cls(1, A=A, IL=bp.dyn.IL(1, g_max=0.0, E=-70.))
53+
neu.reset_state()
54+
neu.add_inp_fun('syn', lambda V, init=0.: init + 10.0) # constant synaptic density
55+
bp.share.save(t=0., dt=0.1, i=0)
56+
V0 = float(neu.V.value[0]); neu.update(0.); V1 = float(neu.V.value[0])
57+
print(cls.__name__, V1 - V0) # expected dt*syn/C = 1.0
58+
# CondNeuGroupLTC -> 1.0 (correct)
59+
# CondNeuGroup -> 9.99e-4 (wrong: scaled by 1e-3/A)
60+
```
61+
- Fix: In `CondNeuGroup`, evaluate the synaptic current at the pre-step `self.V` (preserving
62+
the non-LTC "evaluate synapses at the fixed membrane potential" semantics) but inject it
63+
into the derivative as an unscaled **density**, exactly like channel currents — instead of
64+
folding it into the pre-scaled external `x`. Implemented by stashing the precomputed
65+
synaptic density on the instance and adding it inside `CondNeuGroup.derivative`.
66+
- Tests: `channels`/`neurons``hh_test.py::test_condneugroup_synaptic_current_scaling`
67+
(new) asserts `CondNeuGroup` and `CondNeuGroupLTC` give identical synaptic drive for
68+
`A != 1e-3`.
69+
- Status: fixed
70+
71+
---
72+
73+
### P8-L1 — `ICaN_IS2008.derivative` variable names swapped (`phi_p` holds steady-state, `p_inf` holds tau) [Low]
74+
- File: `brainpy/dyn/channels/calcium.py:332-335`
75+
- Category: style
76+
- What: In `derivative`, the local `phi_p` actually holds the steady-state activation
77+
`p_inf(V)` and `p_inf` holds the time constant `tau_p(V)`; the returned value
78+
`self.phi * (phi_p - p) / p_inf` is numerically correct but the names are inverted and
79+
confusing.
80+
- Why it's a bug: Readability/maintenance hazard only; math is correct.
81+
- Repro: static.
82+
- Fix: recorded only.
83+
- Tests: none.
84+
- Status: recorded-only
85+
86+
---
87+
88+
### P8-L2 — Pervasive non-rendering NumPy-doc section markers (`Parameters::`, `References::`, `See Also::`) [Low]
89+
- File: `brainpy/dyn/channels/*.py`, `brainpy/dyn/ions/*.py`, `brainpy/dyn/neurons/hh.py`
90+
(e.g. `sodium.py:88`, `potassium.py:95`, `calcium.py:92`, many more)
91+
- Category: style/docs
92+
- What: Docstrings use `Parameters::`/`References::`/`See Also::` (literal double-colon
93+
RST literal-block markers) instead of the NumPy-doc underline form. These will not render
94+
as proper sections in Sphinx/numpydoc and violate the project docstring style.
95+
- Why it's a bug: Documentation only; no runtime effect.
96+
- Repro: static.
97+
- Fix: recorded only (out of risk budget; mechanical but very large surface).
98+
- Tests: none.
99+
- Status: recorded-only
100+
101+
---
102+
103+
### P8-L3 — `IAHP_De1994.reset_state` has a dead/duplicate assignment to `self.p` [Low]
104+
- File: `brainpy/dyn/channels/potassium_calcium_compatible.py:133`
105+
- Category: style / dead code
106+
- What: `self.p[:] = C2 / C3` is executed and then immediately overwritten by
107+
`self.p.value = bm.broadcast_to(C2 / C3, size)` at the end of the method. The first
108+
in-place assignment is redundant (and would also fail for a batched `C2/C3` whose shape
109+
differs from the unbatched `self.p`, though in practice `reset_state` is called before
110+
batching expansion).
111+
- Why it's a bug: Dead code / minor fragility; behavior currently correct.
112+
- Repro: static.
113+
- Fix: recorded only.
114+
- Tests: none.
115+
- Status: recorded-only
116+
117+
---
118+
119+
### P8-L4 — `CalciumDyna.reset_state` passes `batch_size` as the `mode` positional of `variable(...)` [Low]
120+
- File: `brainpy/dyn/ions/calcium.py:133`
121+
- Category: edge/api
122+
- What: `variable(self._C_initializer, batch_size, self.varshape)` passes `batch_size`
123+
(an `int`/`None`/`Mode`) into the second positional parameter of `brainpy.initialize.variable`,
124+
whose signature is `variable(data, batch_or_mode, sizes, ...)`. This happens to work for the
125+
tested `int`/`None`/`Mode` values that `batch_or_mode` accepts, but is fragile and easy to
126+
misread.
127+
- Why it's a bug: Works for current call sites; latent fragility only.
128+
- Repro: static.
129+
- Fix: recorded only.
130+
- Tests: none.
131+
- Status: recorded-only

0 commit comments

Comments
 (0)