@@ -168,13 +168,20 @@ class FeaturePlot(GraphPlotWidget):
168168 # language=rst
169169 """
170170 Abstract base for plotting a connection feature's ``value`` matrix as a live,
171- GPU-resident heatmap (x = target neuron, y = source neuron, color = value).
172-
173- Shared here: locating the :class:`AbstractFeature` in the network and driving
174- the per-frame texture migration. Subclasses describe *how* to colour a specific
175- feature by overriding the ``texture_format`` / ``cmap`` knobs and ``_clim()``.
176- Same zero-copy contract as RasterPlot/VoltagePlot -- the value never leaves the
177- GPU (see [[gpu-only-rendering]]).
171+ GPU-resident heatmap (x = target neuron, y = source neuron, color = value),
172+ with a colorbar legend.
173+
174+ Shared here: locating the :class:`AbstractFeature` in the network, driving the
175+ per-frame texture migration, and building the colorbar. Subclasses describe
176+ *how* to colour a specific feature by overriding the ``texture_format`` /
177+ ``cmap`` knobs (and optionally ``_clim()``). Same zero-copy contract as
178+ RasterPlot/VoltagePlot -- the value never leaves the GPU (see
179+ [[gpu-only-rendering]]).
180+
181+ Color limits default to the feature's declared ``range`` (e.g. a Weight built
182+ with ``range=[-1, 1]``); when that range is non-finite (the default Weight
183+ range is ``[-inf, +inf]``) we fall back to a symmetric range read once from the
184+ initial values. Pass ``clim=(lo, hi)`` to override.
178185 """
179186
180187 # --- knobs a subclass overrides for its feature ---
@@ -184,15 +191,17 @@ class FeaturePlot(GraphPlotWidget):
184191 y_label = "Source neuron"
185192
186193 def __init__ (self , source : str , target : str , feature_name : str ,
187- refresh_every : int = 1 ):
194+ clim : tuple [ float , float ] | None = None , refresh_every : int = 1 ):
188195 super ().__init__ () # heatmap: both axes linked to the panzoom camera
189196 self .source = source # source layer name (connection key part 1)
190197 self .target = target # target layer name (connection key part 2)
191198 self .feature_name = feature_name
199+ self ._clim_override = clim # explicit color limits; else range/data (see _clim)
192200 self .refresh_every = max (1 , refresh_every ) # throttle big-matrix re-uploads
193201 self .connection = None # Initialized in prime()
194202 self .feature = None
195203 self .visual = None
204+ self .colorbar = None
196205
197206 def prime (self , network ):
198207 self .connection = network .connections [(self .source , self .target )]
@@ -206,18 +215,31 @@ def prime(self, network):
206215 f"sparse={ getattr (value , 'is_sparse' , None )} ."
207216 )
208217 rows , cols = value .shape # (source.n, target.n)
218+ clim = self ._clim ()
209219
210220 self .visual = FeatureMatrix (
211221 rows = rows , cols = cols ,
212222 value_getter = lambda : self .feature .value , # re-fetch: value may be rebound
213223 texture_format = self .texture_format ,
214- clim = self . _clim () ,
224+ clim = clim ,
215225 cmap = self .cmap ,
216226 )
217227 self .view .add (self .visual )
218228 self .view .camera .rect = (0 , 0 , cols , rows )
219229 self .y_axis .axis .axis_label = self .y_label
220230 self .x_axis .axis .axis_label = self .x_label
231+ self ._add_colorbar (clim )
232+
233+ def _add_colorbar (self , clim ):
234+ # Vertical bar to the right of the view (grid col 2). White text/border: the
235+ # canvas bg is black and ColorBarWidget defaults to black. label_color also
236+ # colours the min/max tick labels (drawn from clim).
237+ self .colorbar = scene .ColorBarWidget (
238+ cmap = self .cmap , orientation = 'right' ,
239+ label = self .feature_name , clim = clim ,
240+ label_color = 'white' , border_color = 'white' , border_width = 1 ,
241+ )
242+ self .grid .add_widget (self .colorbar , row = 0 , col = 2 ).width_max = 95
221243
222244 def render (self , t ):
223245 if t % self .refresh_every == 0 :
@@ -226,36 +248,41 @@ def render(self, t):
226248 def get_history (self ):
227249 return None # values live on the GPU; no CPU copy kept
228250
229- @abstractmethod
230251 def _clim (self ):
231252 # language=rst
232253 """Return ``(low, high)`` colour limits in feature-value units."""
233- pass
254+ if self ._clim_override is not None :
255+ return tuple (self ._clim_override )
256+
257+ # Prefer the feature's declared range, when it's a finite (lo < hi) scalar pair.
258+ rng = getattr (self .feature , "range" , None )
259+ if rng is not None and len (rng ) == 2 :
260+ try :
261+ lo , hi = float (rng [0 ]), float (rng [1 ])
262+ if np .isfinite (lo ) and np .isfinite (hi ) and lo < hi :
263+ return (lo , hi )
264+ except (TypeError , ValueError ):
265+ pass # e.g. a non-scalar tensor range -> fall through to data-derived
266+
267+ # Fallback: symmetric range from the initial values (a scalar reduction -- two
268+ # floats reach the host, not the matrix -- so the zero-copy render path is
269+ # untouched). Symmetric keeps 0 at the colormap center; rounded for a clean
270+ # colorbar label.
271+ m = self .feature .value .abs ().max ().item ()
272+ if not np .isfinite (m ) or m == 0.0 :
273+ return (- 1.0 , 1.0 )
274+ m = round (m , 3 )
275+ return (- m , m )
234276
235277
236278class WeightPlot (FeaturePlot ):
237279 # language=rst
238280 """
239281 Live heatmap of a :class:`Weight` feature's values. Uses a diverging colormap
240282 centered at 0 so excitatory (positive) and inhibitory (negative) weights read
241- as opposite colours.
283+ as opposite colours. Color limits follow the Weight's ``range`` if finite, else
284+ a symmetric range from the initial weights (see :class:`FeaturePlot`).
242285 """
243286
244287 texture_format = np .float32
245288 cmap = 'coolwarm' # diverging: low=blue, 0=white, high=red
246-
247- def __init__ (self , source : str , target : str , feature_name : str ,
248- clim : tuple [float , float ] | None = None , refresh_every : int = 1 ):
249- super ().__init__ (source , target , feature_name , refresh_every = refresh_every )
250- self ._clim_override = clim # explicit color limits; else symmetric-from-data
251-
252- def _clim (self ):
253- if self ._clim_override is not None :
254- return self ._clim_override
255- # One-time symmetric range from the initial weights. A scalar reduction (two
256- # floats reach the host, not the matrix), so the zero-copy render path is
257- # untouched; symmetric keeps 0 at the colormap center (white).
258- m = self .feature .value .abs ().max ().item ()
259- if not np .isfinite (m ) or m == 0.0 :
260- return (- 1.0 , 1.0 )
261- return (- m , m )
0 commit comments