Skip to content

Commit 5d06443

Browse files
authored
fix(analysis): fixed-point classification, nullcline selection, GD batches, plot kwargs (#849)
fix(analysis): fixed-point classification, nullcline selection, GD batch count, plot kwargs - 2D star vs degenerate-node stability classification was inverted and the STAR branch was unreachable dead code; correctly distinguish star (scalar*I) from defective node (Critical) - phase-plane select_candidates='nullclines' tested the fy condition twice, silently dropping all fx-nullcline candidate points; test fx OR fy (High) - slow_points num_opt_loops = int(num_opt/num_batch) was 0 when num_opt<num_batch -> empty losses -> concatenate crash; use max(1, ceil(...)) (Medium) - streamplot used plot_style.get('linewidth') (not pop), passing linewidth twice -> TypeError; use pop (Medium) - set_markersize wrote to a typo local, leaving the module global stale (Medium) Findings recorded in docs/issues-found-20260619-analysis.md
1 parent 9845a46 commit 5d06443

10 files changed

Lines changed: 335 additions & 31 deletions

brainpy/analysis/highdim/slow_points.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,10 @@ def batch_train(start_i, n_batch):
376376
print(f"Optimizing with {optimizer} to find fixed points:")
377377
opt_losses = []
378378
do_stop = False
379-
num_opt_loops = int(num_opt / num_batch)
379+
# Always run at least one batch, even when ``num_opt < num_batch`` (where
380+
# ``int(num_opt / num_batch)`` would otherwise be 0 and leave ``opt_losses``
381+
# empty, crashing the subsequent ``jnp.concatenate``).
382+
num_opt_loops = max(1, int(np.ceil(num_opt / num_batch)))
380383
for oidx in range(num_opt_loops):
381384
if do_stop:
382385
break

brainpy/analysis/highdim/slow_points_coverage_test.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,19 @@ def step(x, scale):
155155
assert f.opt_losses.ndim == 1
156156

157157

158+
def test_gd_small_num_opt_runs_at_least_one_batch():
159+
"""Regression for P13-M1: ``num_opt < num_batch`` previously gave
160+
``num_opt_loops = int(num_opt / num_batch) == 0``, leaving ``opt_losses``
161+
empty and crashing on ``jnp.concatenate``. At least one batch must run."""
162+
f = bp.analysis.SlowPointFinder(f_cell=_linear_step, f_type=CONTINUOUS, verbose=False)
163+
rng = bm.random.RandomState(2)
164+
# num_opt (50) < num_batch (100) -> would crash before the fix.
165+
f.find_fps_with_gd_method(rng.random((4, 2)) * 0.2, num_opt=50, num_batch=100)
166+
assert f.num_fps == 4
167+
assert f.opt_losses.ndim == 1
168+
assert f.opt_losses.shape[0] == 100
169+
170+
158171
# --------------------------------------------------------------------------- #
159172
# full pipeline for a callable system
160173
# --------------------------------------------------------------------------- #

brainpy/analysis/lowdim/lowdim_phase_plane.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,10 @@ def plot_vector_field(self, with_plot=True, with_return=False,
226226
elif plot_method == 'streamplot':
227227
if plot_style is None:
228228
plot_style = dict(arrowsize=1.2, density=1, color='thistle')
229-
linewidth = plot_style.get('linewidth', None)
229+
# Pop (not get) so a user-supplied ``linewidth`` is consumed and
230+
# only passed once -- otherwise it is forwarded both explicitly
231+
# and via ``**plot_style`` -> TypeError (multiple values).
232+
linewidth = plot_style.pop('linewidth', None)
230233
if linewidth is None:
231234
if (not np.isnan(dx).any()) and (not np.isnan(dy).any()):
232235
min_width, max_width = 0.5, 5.5
@@ -326,7 +329,7 @@ def plot_fixed_point(self, with_plot=True, with_return=False, show=False,
326329
candidates = jnp.vstack(candidates)
327330
elif select_candidates == 'nullclines':
328331
candidates = [self.analyzed_results[key][0] for key in self.analyzed_results.keys()
329-
if key.startswith(C.fy_nullcline_points) or key.startswith(C.fy_nullcline_points)]
332+
if key.startswith(C.fx_nullcline_points) or key.startswith(C.fy_nullcline_points)]
330333
if len(candidates) == 0:
331334
raise errors.AnalyzerError(f'No nullcline points are found, please call '
332335
f'".{self.plot_nullcline.__name__}()" first.')

brainpy/analysis/lowdim/lowdim_phase_plane_coverage_test.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,18 @@ def test_pp2d_quiver_and_return():
111111
assert dx.shape == dy.shape
112112

113113

114-
# NOTE (defect): passing ``linewidth`` inside ``plot_style`` to the
115-
# ``streamplot`` branch crashes. The code reads ``plot_style.get('linewidth')``
116-
# but never pops it, so ``pyplot.streamplot(..., linewidth=linewidth,
117-
# **plot_style)`` passes ``linewidth`` twice -> ``TypeError: got multiple values
118-
# for keyword argument 'linewidth'`` (lowdim_phase_plane.py:235).
119-
def test_pp2d_streamplot_custom_linewidth_is_buggy():
114+
# Regression for P13-M2: passing ``linewidth`` inside ``plot_style`` to the
115+
# ``streamplot`` branch used to crash because the code read
116+
# ``plot_style.get('linewidth')`` without popping it, so ``linewidth`` was
117+
# forwarded twice (explicitly and via ``**plot_style``) ->
118+
# ``TypeError: got multiple values for keyword argument 'linewidth'``.
119+
# The fix pops the key, so a user-supplied ``linewidth`` is honoured exactly once.
120+
def test_pp2d_streamplot_custom_linewidth():
120121
pp = bp.analysis.PhasePlane2D(model=_fhn_2d(),
121122
target_vars={'V': [-3., 3.], 'w': [-1., 3.]},
122123
resolutions=0.2)
123-
with pytest.raises(TypeError):
124-
pp.plot_vector_field(plot_method='streamplot', plot_style=dict(linewidth=1.0))
124+
# should no longer raise
125+
pp.plot_vector_field(plot_method='streamplot', plot_style=dict(linewidth=1.0))
125126

126127

127128
def test_pp2d_unknown_plot_method_raises():
@@ -170,6 +171,37 @@ def test_pp2d_fixed_point_after_nullcline():
170171
assert fps is not None
171172

172173

174+
def test_pp2d_nullclines_selector_uses_both_nullclines():
175+
"""Regression for P13-H2: the ``select_candidates='nullclines'`` branch must
176+
gather candidates from *both* the fx- and fy-nullcline point sets. The old
177+
code tested ``startswith(fy_...)`` twice, so fx-nullcline points were dropped.
178+
"""
179+
import jax.numpy as jnp
180+
from brainpy.analysis import constants as C
181+
182+
pp = bp.analysis.PhasePlane2D(model=_fhn_2d(),
183+
target_vars={'V': [-3., 3.], 'w': [-1., 3.]},
184+
resolutions=0.1)
185+
pp.plot_nullcline()
186+
187+
fx_keys = [k for k in pp.analyzed_results if k.startswith(C.fx_nullcline_points)]
188+
fy_keys = [k for k in pp.analyzed_results if k.startswith(C.fy_nullcline_points)]
189+
assert len(fx_keys) > 0 and len(fy_keys) > 0
190+
n_fx = sum(pp.analyzed_results[k][0].shape[0] for k in fx_keys)
191+
n_fy = sum(pp.analyzed_results[k][0].shape[0] for k in fy_keys)
192+
193+
# Reproduce the candidate gathering used by ``select_candidates='nullclines'``.
194+
candidates = [pp.analyzed_results[k][0] for k in pp.analyzed_results.keys()
195+
if k.startswith(C.fx_nullcline_points) or k.startswith(C.fy_nullcline_points)]
196+
candidates = jnp.vstack(candidates)
197+
# union of both nullcline candidate sets, not just one of them
198+
assert candidates.shape[0] == n_fx + n_fy
199+
assert candidates.shape[0] > n_fy
200+
201+
fps = pp.plot_fixed_point(select_candidates='nullclines', with_return=True)
202+
assert fps is not None
203+
204+
173205
# --------------------------------------------------------------------------- #
174206
# PhasePlane2D trajectory + limit cycle
175207
# --------------------------------------------------------------------------- #

brainpy/analysis/plotstyle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,6 @@ def set_markersize(markersize):
7676
if not isinstance(markersize, int):
7777
raise TypeError(f"Must be an integer, but got {type(markersize)}: {markersize}")
7878
global _markersize
79-
__markersize = markersize
79+
_markersize = markersize
8080
for key in tuple(plot_schema.keys()):
8181
plot_schema[key]['markersize'] = markersize

brainpy/analysis/plotstyle_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025 BrainX Ecosystem Limited. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
# ==============================================================================
16+
import pytest
17+
18+
from brainpy.analysis import plotstyle
19+
20+
21+
def test_set_markersize_updates_global():
22+
"""Regression for P13-H1: ``set_markersize`` must update the module global
23+
``_markersize`` (it previously assigned to a typo local ``__markersize``)."""
24+
original = plotstyle._markersize
25+
try:
26+
plotstyle.set_markersize(33)
27+
# per-key schema entries updated
28+
assert plotstyle.plot_schema[plotstyle.SADDLE_NODE]['markersize'] == 33
29+
# module global updated, not just the per-key dicts
30+
assert plotstyle._markersize == 33
31+
finally:
32+
plotstyle.set_markersize(original)
33+
34+
35+
def test_set_markersize_type_check():
36+
with pytest.raises(TypeError):
37+
plotstyle.set_markersize(1.5)
38+
39+
40+
def test_set_plot_schema_validations():
41+
with pytest.raises(TypeError):
42+
plotstyle.set_plot_schema(123)
43+
with pytest.raises(KeyError):
44+
plotstyle.set_plot_schema('not-a-real-fixed-point-type')
45+
# valid update
46+
plotstyle.set_plot_schema(plotstyle.SADDLE_NODE, color='black')
47+
assert plotstyle.plot_schema[plotstyle.SADDLE_NODE]['color'] == 'black'

brainpy/analysis/stability.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,22 +145,25 @@ def stability_analysis(derivatives):
145145
elif e > 0:
146146
return UNSTABLE_NODE_2D
147147
else:
148-
w = np.linalg.eigvals(derivatives)
149-
if w[0] == w[1]:
150-
return UNSTABLE_DEGENERATE_2D
151-
else:
148+
# Repeated eigenvalue. A star (proper) node has a full
149+
# eigenspace, which happens iff the matrix is a scalar
150+
# multiple of the identity (b == c == 0 and a == d).
151+
# Otherwise the matrix is defective -> degenerate (improper) node.
152+
if b == 0 and c == 0 and a == d:
152153
return UNSTABLE_STAR_2D
154+
else:
155+
return UNSTABLE_DEGENERATE_2D
153156
else:
154157
if e < 0:
155158
return STABLE_FOCUS_2D
156159
elif e > 0:
157160
return STABLE_NODE_2D
158161
else:
159-
w = np.linalg.eigvals(derivatives)
160-
if w[0] == w[1]:
161-
return STABLE_DEGENERATE_2D
162-
else:
162+
# Repeated eigenvalue. See the unstable branch above.
163+
if b == 0 and c == 0 and a == d:
163164
return STABLE_STAR_2D
165+
else:
166+
return STABLE_DEGENERATE_2D
164167

165168
elif np.size(derivatives) == 9: # 3D dynamical system
166169
eigenvalues = np.linalg.eigvals(np.array(derivatives))

brainpy/analysis/stability_coverage_test.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,13 @@ def test_2d_unstable_focus_and_node():
8888

8989

9090
def test_2d_unstable_degenerate_and_star():
91-
# p > 0, e == 0. Distinct construction so eigenvalues equal -> degenerate.
91+
# p > 0, e == 0. A scalar multiple of the identity is a proper (star) node:
92+
# full 2-D eigenspace. (P13-C1: previously mislabelled as degenerate.)
9293
J = [[2., 0.], [0., 2.]] # trace 4, det 4, e = 16 - 16 = 0; eigvals both 2
93-
assert stability_analysis(J) == st.UNSTABLE_DEGENERATE_2D
94-
# p > 0, e == 0 but eigvals not literally equal (numerical) -> star branch.
95-
# Use a Jordan-like block so np.linalg.eigvals returns slightly different.
94+
assert stability_analysis(J) == st.UNSTABLE_STAR_2D
95+
# p > 0, e == 0 with a defective (Jordan) block -> degenerate (improper) node.
9696
J = [[2., 1.], [0., 2.]] # trace 4, det 4, e = 0; defective matrix
97-
res = stability_analysis(J)
98-
assert res in (st.UNSTABLE_DEGENERATE_2D, st.UNSTABLE_STAR_2D)
97+
assert stability_analysis(J) == st.UNSTABLE_DEGENERATE_2D
9998

10099

101100
def test_2d_stable_focus_and_node():
@@ -108,13 +107,13 @@ def test_2d_stable_focus_and_node():
108107

109108

110109
def test_2d_stable_degenerate_and_star():
111-
# p < 0, e == 0 -> stable degenerate (eigvals equal)
110+
# p < 0, e == 0. Scalar multiple of identity -> proper (star) node.
111+
# (P13-C1: previously mislabelled as degenerate.)
112112
J = [[-2., 0.], [0., -2.]] # trace -4, det 4, e = 0
113-
assert stability_analysis(J) == st.STABLE_DEGENERATE_2D
114-
# defective -> degenerate or star
113+
assert stability_analysis(J) == st.STABLE_STAR_2D
114+
# defective (Jordan) block -> degenerate (improper) node.
115115
J = [[-2., 1.], [0., -2.]]
116-
res = stability_analysis(J)
117-
assert res in (st.STABLE_DEGENERATE_2D, st.STABLE_STAR_2D)
116+
assert stability_analysis(J) == st.STABLE_DEGENERATE_2D
118117

119118

120119
# --------------------------------------------------------------------------- #

brainpy/analysis/stability_test.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,44 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
# ==============================================================================
16+
import numpy as np
17+
1618
from brainpy.analysis.stability import *
1719

1820

1921
def test_d1():
2022
assert stability_analysis(1.) == UNSTABLE_POINT_1D
2123
assert stability_analysis(-1.) == STABLE_POINT_1D
2224
assert stability_analysis(0.) == SADDLE_NODE
25+
26+
27+
# --------------------------------------------------------------------------- #
28+
# 2D star (proper) vs degenerate (improper) node — regression for P13-C1.
29+
#
30+
# A *star* node has a full 2-D eigenspace, i.e. the Jacobian is a scalar
31+
# multiple of the identity (b == c == 0 and a == d). A *degenerate*
32+
# (improper) node has a repeated eigenvalue but is defective (single
33+
# eigenvector), e.g. a Jordan block.
34+
# --------------------------------------------------------------------------- #
35+
def test_2d_stable_star_proper_node():
36+
# -I : repeated eigenvalue -1, full eigenspace -> stable STAR.
37+
J = np.array([[-1., 0.], [0., -1.]])
38+
assert stability_analysis(J) == STABLE_STAR_2D
39+
40+
41+
def test_2d_stable_degenerate_defective():
42+
# Jordan block with repeated eigenvalue -1, defective -> stable DEGENERATE.
43+
J = np.array([[-1., 1.], [0., -1.]])
44+
assert stability_analysis(J) == STABLE_DEGENERATE_2D
45+
46+
47+
def test_2d_unstable_star_proper_node():
48+
# 2*I : repeated eigenvalue +2, full eigenspace -> unstable STAR.
49+
J = np.array([[2., 0.], [0., 2.]])
50+
assert stability_analysis(J) == UNSTABLE_STAR_2D
51+
52+
53+
def test_2d_unstable_degenerate_defective():
54+
# Jordan block with repeated eigenvalue +2, defective -> unstable DEGENERATE.
55+
J = np.array([[2., 1.], [0., 2.]])
56+
assert stability_analysis(J) == UNSTABLE_DEGENERATE_2D

0 commit comments

Comments
 (0)