@@ -214,6 +214,121 @@ def _compute_time_info(data: dict[str, sc.DataArray]) -> str | None:
214214 return f'{ end_str } (Lag: { lag_s :.1f} s)'
215215
216216
217+ @dataclass (frozen = True )
218+ class OverlayUnitKey :
219+ """Unit signature for cross-layer overlay validation.
220+
221+ Captures the units of all plotting dimensions and (optionally) the value
222+ dimension. Two keys are compatible when every shared kdim label has the
223+ same unit string and, if both keys carry a vdim unit, those match too.
224+ """
225+
226+ kdim_units : tuple [tuple [str , str | None ], ...] = ()
227+ vdim_unit : str | None = None
228+
229+ @classmethod
230+ def from_data_array (
231+ cls ,
232+ da : sc .DataArray ,
233+ * ,
234+ include_vdim : bool = True ,
235+ ) -> OverlayUnitKey :
236+ """Build a key from a scipp DataArray using its dimension coordinates."""
237+ kdim_units = tuple (
238+ sorted (
239+ (dim , _unit_str (da .coords [dim ].unit ))
240+ for dim in da .dims
241+ if dim in da .coords
242+ )
243+ )
244+ vdim_unit = _unit_str (da .unit ) if include_vdim else None
245+ return cls (kdim_units = kdim_units , vdim_unit = vdim_unit )
246+
247+
248+ def _unit_str (unit : sc .Unit | None ) -> str | None :
249+ """Convert a scipp unit to its string representation, or None."""
250+ return str (unit ) if unit is not None else None
251+
252+
253+ def _validate_overlay_units (data : dict [ResultKey , sc .DataArray ]) -> None :
254+ """Validate that all DataArrays in an overlay share compatible units.
255+
256+ Raises
257+ ------
258+ ValueError
259+ If dimension names, coordinate units, or value units do not match.
260+ """
261+ if len (data ) < 2 :
262+ return
263+
264+ items = iter (data .items ())
265+ ref_key , ref = next (items )
266+ ref_source = ref_key .job_id .source_name
267+
268+ for key , da in items :
269+ source = key .job_id .source_name
270+ if ref .dims != da .dims :
271+ raise ValueError (
272+ f"Cannot overlay '{ ref_source } ' and '{ source } ': "
273+ f"dimension mismatch ({ ref .dims } vs { da .dims } )"
274+ )
275+ for dim in ref .dims :
276+ if dim in ref .coords and dim in da .coords :
277+ u_ref = ref .coords [dim ].unit
278+ u_da = da .coords [dim ].unit
279+ if u_ref != u_da :
280+ raise ValueError (
281+ f"Cannot overlay '{ ref_source } ' and '{ source } ': "
282+ f"unit mismatch for coordinate '{ dim } ' "
283+ f"({ u_ref } vs { u_da } )"
284+ )
285+ if ref .unit != da .unit :
286+ raise ValueError (
287+ f"Cannot overlay '{ ref_source } ' and '{ source } ': "
288+ f"value unit mismatch ({ ref .unit } vs { da .unit } )"
289+ )
290+
291+
292+ def validate_cross_layer_units (
293+ keys : list [tuple [str , OverlayUnitKey ]],
294+ ) -> str | None :
295+ """Check overlay unit keys from multiple layers for compatibility.
296+
297+ Parameters
298+ ----------
299+ keys:
300+ Pairs of (layer label, unit key) for each data-carrying layer.
301+
302+ Returns
303+ -------
304+ :
305+ An error message if units are incompatible, or None if compatible.
306+ """
307+ if len (keys ) < 2 :
308+ return None
309+
310+ ref_label , ref_key = keys [0 ]
311+ ref_kdims = dict (ref_key .kdim_units )
312+
313+ for label , key in keys [1 :]:
314+ other_kdims = dict (key .kdim_units )
315+ for dim_label , ref_unit in ref_kdims .items ():
316+ if dim_label in other_kdims and ref_unit != other_kdims [dim_label ]:
317+ return (
318+ f"Cannot overlay layers '{ ref_label } ' and '{ label } ': "
319+ f"unit mismatch for coordinate '{ dim_label } ' "
320+ f"({ ref_unit } vs { other_kdims [dim_label ]} )"
321+ )
322+ if ref_key .vdim_unit is not None and key .vdim_unit is not None :
323+ if ref_key .vdim_unit != key .vdim_unit :
324+ return (
325+ f"Cannot overlay layers '{ ref_label } ' and '{ label } ': "
326+ f"value unit mismatch "
327+ f"({ ref_key .vdim_unit } vs { key .vdim_unit } )"
328+ )
329+ return None
330+
331+
217332class Plotter :
218333 """
219334 Base class for plots that support autoscaling.
@@ -245,6 +360,7 @@ def __init__(
245360 """
246361 self ._normalize_to_rate = normalize_to_rate
247362 self ._cached_state : Any | None = None
363+ self ._overlay_unit_key : OverlayUnitKey | None = None
248364 self ._presenters : weakref .WeakSet [PresenterBase ] = weakref .WeakSet ()
249365 self .autoscaler_kwargs = kwargs
250366 self .autoscalers : dict [ResultKey , Autoscaler ] = {}
@@ -257,6 +373,25 @@ def __init__(
257373 # with responsive mode in Panel containers (upstream bug).
258374 self ._sizing_opts : dict [str , Any ] = {'responsive' : True }
259375
376+ @property
377+ def overlay_unit_key (self ) -> OverlayUnitKey | None :
378+ """Unit signature from the last compute(), for cross-layer validation."""
379+ return self ._overlay_unit_key
380+
381+ def _extract_overlay_unit_key (
382+ self , data : dict [ResultKey , sc .DataArray ]
383+ ) -> OverlayUnitKey | None :
384+ """Extract an overlay unit key from input data.
385+
386+ Default implementation uses dimension coordinates and value unit from the
387+ first DataArray. Subclasses with non-standard coordinate structures (e.g.,
388+ ROI plotters) should override this method.
389+ """
390+ if not data :
391+ return None
392+ da = next (iter (data .values ()))
393+ return OverlayUnitKey .from_data_array (da )
394+
260395 @staticmethod
261396 def _make_tick_opts (tick_params : TickParams | None ) -> dict [str , Any ]:
262397 """
@@ -430,9 +565,13 @@ def compute(
430565 if self ._normalize_to_rate :
431566 data = {key : _normalize_to_rate (da ) for key , da in data .items ()}
432567
568+ self ._overlay_unit_key = self ._extract_overlay_unit_key (data )
569+
433570 resolver = title_resolver or TitleResolver ()
434571 plots : list [hv .Element ] = []
435572 try :
573+ if self .layout_params .combine_mode == 'overlay' :
574+ _validate_overlay_units (data )
436575 for data_key , da in data .items ():
437576 label = resolver .get_legend_label (
438577 data_key .job_id .source_name , data_key .output_name
0 commit comments