77import weakref
88from collections .abc import Callable
99from dataclasses import dataclass
10+ from dataclasses import field as dataclass_field
1011from typing import Any , ClassVar , cast
1112
1213import holoviews as hv
@@ -216,117 +217,66 @@ def _compute_time_info(data: dict[str, sc.DataArray]) -> str | None:
216217
217218
218219@dataclass (frozen = True )
219- class OverlayUnitKey :
220- """Unit signature for cross-layer overlay validation.
220+ class CanvasSpec :
221+ """Unit metadata extracted from a scipp DataArray for overlay validation.
221222
222- Captures the units of all plotting dimensions and (optionally) the value
223- dimension. Two keys are compatible when every shared kdim label has the
224- same unit string and, if both keys carry a vdim unit, those match too .
223+ Captures coordinate units (keyed by dimension name) and the value unit.
224+ Two overlays are compatible when they share the same coordinate dimensions
225+ with matching units and matching value units .
225226 """
226227
227- kdim_units : tuple [ tuple [ str , str | None ], ... ] = ( )
228- vdim_unit : str | None = None
228+ coord_units : dict [ str , sc . Unit ] = dataclass_field ( default_factory = dict )
229+ value_unit : sc . Unit = dataclass_field ( default = sc . units . dimensionless )
229230
230231 @classmethod
231- def from_data_array (
232- cls ,
233- da : sc .DataArray ,
234- * ,
235- include_vdim : bool = True ,
236- ) -> OverlayUnitKey :
237- """Build a key from a scipp DataArray using its dimension coordinates."""
238- kdim_units = tuple (
239- sorted (
240- (dim , _unit_str (da .coords [dim ].unit ))
241- for dim in da .dims
242- if dim in da .coords
243- )
232+ def from_data_array (cls , da : sc .DataArray ) -> CanvasSpec :
233+ """Build from a scipp DataArray using its dimension coordinates."""
234+ return cls (
235+ coord_units = {
236+ dim : da .coords [dim ].unit for dim in da .dims if dim in da .coords
237+ },
238+ value_unit = da .unit ,
244239 )
245- vdim_unit = _unit_str (da .unit ) if include_vdim else None
246- return cls (kdim_units = kdim_units , vdim_unit = vdim_unit )
247-
248-
249- def _unit_str (unit : sc .Unit | None ) -> str | None :
250- """Convert a scipp unit to its string representation, or None."""
251- return str (unit ) if unit is not None else None
252-
253-
254- def _validate_overlay_units (data : dict [ResultKey , sc .DataArray ]) -> None :
255- """Validate that all DataArrays in an overlay share compatible units.
256-
257- Raises
258- ------
259- ValueError
260- If dimension names, coordinate units, or value units do not match.
261- """
262- if len (data ) < 2 :
263- return
264-
265- items = iter (data .items ())
266- ref_key , ref = next (items )
267- ref_source = ref_key .job_id .source_name
268-
269- for key , da in items :
270- source = key .job_id .source_name
271- if ref .dims != da .dims :
272- raise ValueError (
273- f"Cannot overlay '{ ref_source } ' and '{ source } ': "
274- f"dimension mismatch ({ ref .dims } vs { da .dims } )"
275- )
276- for dim in ref .dims :
277- if dim in ref .coords and dim in da .coords :
278- u_ref = ref .coords [dim ].unit
279- u_da = da .coords [dim ].unit
280- if u_ref != u_da :
281- raise ValueError (
282- f"Cannot overlay '{ ref_source } ' and '{ source } ': "
283- f"unit mismatch for coordinate '{ dim } ' "
284- f"({ u_ref } vs { u_da } )"
285- )
286- if ref .unit != da .unit :
287- raise ValueError (
288- f"Cannot overlay '{ ref_source } ' and '{ source } ': "
289- f"value unit mismatch ({ ref .unit } vs { da .unit } )"
290- )
291240
292241
293- def validate_cross_layer_units (
294- keys : list [tuple [str , OverlayUnitKey ]],
242+ def validate_canvas_spec (
243+ entries : list [tuple [str , CanvasSpec ]],
295244) -> str | None :
296- """Check overlay unit keys from multiple layers for compatibility .
245+ """Check that overlay entries share compatible units .
297246
298247 Parameters
299248 ----------
300- keys :
301- Pairs of (layer label, unit key ) for each data-carrying layer.
249+ entries :
250+ Pairs of (label, units ) for each data-carrying element or layer.
302251
303252 Returns
304253 -------
305254 :
306255 An error message if units are incompatible, or None if compatible.
307256 """
308- if len (keys ) < 2 :
257+ if len (entries ) < 2 :
309258 return None
310259
311- ref_label , ref_key = keys [0 ]
312- ref_kdims = dict (ref_key .kdim_units )
313-
314- for label , key in keys [1 :]:
315- other_kdims = dict (key .kdim_units )
316- for dim_label , ref_unit in ref_kdims .items ():
317- if dim_label in other_kdims and ref_unit != other_kdims [dim_label ]:
318- return (
319- f"Cannot overlay layers '{ ref_label } ' and '{ label } ': "
320- f"unit mismatch for coordinate '{ dim_label } ' "
321- f"({ ref_unit } vs { other_kdims [dim_label ]} )"
322- )
323- if ref_key .vdim_unit is not None and key .vdim_unit is not None :
324- if ref_key .vdim_unit != key .vdim_unit :
260+ ref_label , ref = entries [0 ]
261+ for label , other in entries [1 :]:
262+ if ref .coord_units .keys () != other .coord_units .keys ():
263+ return (
264+ f"Cannot overlay '{ ref_label } ' and '{ label } ': "
265+ f"dimension mismatch "
266+ f"({ set (ref .coord_units )} vs { set (other .coord_units )} )"
267+ )
268+ for dim , ref_unit in ref .coord_units .items ():
269+ if ref_unit != other .coord_units [dim ]:
325270 return (
326- f"Cannot overlay layers '{ ref_label } ' and '{ label } ': "
327- f"value unit mismatch "
328- f"({ ref_key . vdim_unit } vs { key . vdim_unit } )"
271+ f"Cannot overlay '{ ref_label } ' and '{ label } ': "
272+ f"unit mismatch for coordinate ' { dim } ' "
273+ f"({ ref_unit } vs { other . coord_units [ dim ] } )"
329274 )
275+ if ref .value_unit != other .value_unit :
276+ return (
277+ f"Cannot overlay '{ ref_label } ' and '{ label } ': "
278+ f"value unit mismatch ({ ref .value_unit } vs { other .value_unit } )"
279+ )
330280 return None
331281
332282
@@ -338,6 +288,8 @@ class Plotter:
338288 This enables efficient polling-based update detection.
339289 """
340290
291+ participates_in_overlay_validation : ClassVar [bool ] = True
292+
341293 def __init__ (
342294 self ,
343295 * ,
@@ -361,7 +313,7 @@ def __init__(
361313 """
362314 self ._normalize_to_rate = normalize_to_rate
363315 self ._cached_state : Any | None = None
364- self ._overlay_unit_key : OverlayUnitKey | None = None
316+ self .canvas_spec : CanvasSpec | None = None
365317 self ._presenters : weakref .WeakSet [PresenterBase ] = weakref .WeakSet ()
366318 self .autoscaler_kwargs = kwargs
367319 self .autoscalers : dict [ResultKey , Autoscaler ] = {}
@@ -374,25 +326,6 @@ def __init__(
374326 # with responsive mode in Panel containers (upstream bug).
375327 self ._sizing_opts : dict [str , Any ] = {'responsive' : True }
376328
377- @property
378- def overlay_unit_key (self ) -> OverlayUnitKey | None :
379- """Unit signature from the last compute(), for cross-layer validation."""
380- return self ._overlay_unit_key
381-
382- def _extract_overlay_unit_key (
383- self , data : dict [ResultKey , sc .DataArray ]
384- ) -> OverlayUnitKey | None :
385- """Extract an overlay unit key from input data.
386-
387- Default implementation uses dimension coordinates and value unit from the
388- first DataArray. Subclasses with non-standard coordinate structures (e.g.,
389- ROI plotters) should override this method.
390- """
391- if not data :
392- return None
393- da = next (iter (data .values ()))
394- return OverlayUnitKey .from_data_array (da )
395-
396329 @staticmethod
397330 def _make_tick_opts (tick_params : TickParams | None ) -> dict [str , Any ]:
398331 """
@@ -566,13 +499,19 @@ def compute(
566499 if self ._normalize_to_rate :
567500 data = {key : _normalize_to_rate (da ) for key , da in data .items ()}
568501
569- self ._overlay_unit_key = self ._extract_overlay_unit_key (data )
570-
571502 resolver = title_resolver or TitleResolver ()
572503 plots : list [hv .Element ] = []
573504 try :
574- if self .layout_params .combine_mode == 'overlay' :
575- _validate_overlay_units (data )
505+ if self .participates_in_overlay_validation and data :
506+ entries = [
507+ (key .job_id .source_name , CanvasSpec .from_data_array (da ))
508+ for key , da in data .items ()
509+ ]
510+ if self .layout_params .combine_mode == 'overlay' :
511+ error = validate_canvas_spec (entries )
512+ if error is not None :
513+ raise ValueError (error )
514+ self .canvas_spec = entries [0 ][1 ]
576515 for data_key , da in data .items ():
577516 label = resolver .get_legend_label (
578517 data_key .job_id .source_name , data_key .output_name
0 commit comments