Repository: yt-project/yt

Issue:
UX: unclear error message for off-axis operations with SPH data
### Bug report

**Bug summary**

We got two users independently reporting the same error message as unclear on Slack within hours.
They are both working with SPH data and trying to create off-axis `LinePlot` and `SlicePlot` respectively.
The problem they face is that off-axis data selection is only supported for cartesian grid based data, but neither the documentation they were taking inspiration from nor the error messages made this clear to them.

**Code for reproduction**

The situation can be reproduced with a sample dataset
```python
import yt
ds = yt.load_sample("Gadget3-snap-format2")

# axis-aligned slice plot, works as expected
yt.SlicePlot(ds, normal="z", fields=[("gas", "density")])

# off axis slice plot, breaks
yt.SlicePlot(ds, normal=(1, 1, 1), fields=[("gas", "density")])
```

**Actual outcome**

<details> <summary> Traceback </summary>

```python-traceback
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ <ipython-input-12-212ffab28f11>:1 in <cell line: 1>                                              │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:2395 in __init__               │
│                                                                                                  │
│   2392 │   │   validate_mesh_fields(cutting, fields)                                             │
│   2393 │   │   # Hard-coding the origin keyword since the other two options                      │
│   2394 │   │   # aren't well-defined for off-axis data objects                                   │
│ ❱ 2395 │   │   PWViewerMPL.__init__(                                                             │
│   2396 │   │   │   self,                                                                         │
│   2397 │   │   │   cutting,                                                                      │
│   2398 │   │   │   bounds,                                                                       │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:919 in __init__                │
│                                                                                                  │
│    916 │   │   │   self._plot_type = kwargs.pop("plot_type")                                     │
│    917 │   │   self._splat_color = kwargs.pop("splat_color", None)                               │
│    918 │   │   self._frb: Optional[FixedResolutionBuffer] = None                                 │
│ ❱  919 │   │   PlotWindow.__init__(self, *args, **kwargs)                                        │
│    920 │   │                                                                                     │
│    921 │   │   # import type here to avoid import cycles                                         │
│    922 │   │   # note that this import statement is actually crucial at runtime:                 │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:270 in __init__                │
│                                                                                                  │
│    267 │   │   │   # Access the dictionary to force the key to be created                        │
│    268 │   │   │   self._units_config[field]                                                     │
│    269 │   │                                                                                     │
│ ❱  270 │   │   self._setup_plots()                                                               │
│    271 │                                                                                         │
│    272 │   def __iter__(self):                                                                   │
│    273 │   │   for ds in self.ts:                                                                │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:1062 in _setup_plots           │
│                                                                                                  │
│   1059 │   │   if self._plot_valid:                                                              │
│   1060 │   │   │   return                                                                        │
│   1061 │   │   if not self._data_valid:                                                          │
│ ❱ 1062 │   │   │   self._recreate_frb()                                                          │
│   1063 │   │   self._colorbar_valid = True                                                       │
│   1064 │   │   field_list = list(set(self.data_source._determine_fields(self.fields)))           │
│   1065 │   │   for f in field_list:                                                              │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/plot_window.py:331 in _recreate_frb           │
│                                                                                                  │
│    328 │   │                                                                                     │
│    329 │   │   # At this point the frb has the valid bounds, size, aliasing, etc.                │
│    330 │   │   if old_fields is None:                                                            │
│ ❱  331 │   │   │   self._frb._get_data_source_fields()                                           │
│    332 │   │   │                                                                                 │
│    333 │   │   │   # New frb, apply default units (if any)                                       │
│    334 │   │   │   for field, field_unit in self._units_config.items():                          │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/fixed_resolution.py:209 in                    │
│ _get_data_source_fields                                                                          │
│                                                                                                  │
│   206 │   │   fields += getattr(self.data_source, "field_data", {}).keys()                       │
│   207 │   │   for f in fields:                                                                   │
│   208 │   │   │   if f not in exclude and f[0] not in self.data_source.ds.particle_types:        │
│ ❱ 209 │   │   │   │   self[f]                                                                    │
│   210 │                                                                                          │
│   211 │   def _get_info(self, item):                                                             │
│   212 │   │   info = {}                                                                          │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/visualization/fixed_resolution.py:167 in __getitem__        │
│                                                                                                  │
│   164 │   │   │   │   b = float(b.in_units("code_length"))                                       │
│   165 │   │   │   bounds.append(b)                                                               │
│   166 │   │                                                                                      │
│ ❱ 167 │   │   buff = self.ds.coordinates.pixelize(                                               │
│   168 │   │   │   self.data_source.axis,                                                         │
│   169 │   │   │   self.data_source,                                                              │
│   170 │   │   │   item,                                                                          │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/geometry/coordinates/cartesian_coordinates.py:226 in        │
│ pixelize                                                                                         │
│                                                                                                  │
│   223 │   │   │   │   data_source, field, bounds, size, antialias, dimension, periodic           │
│   224 │   │   │   )                                                                              │
│   225 │   │   else:                                                                              │
│ ❱ 226 │   │   │   return self._oblique_pixelize(data_source, field, bounds, size, antialias)     │
│   227 │                                                                                          │
│   228 │   def pixelize_line(self, field, start_point, end_point, npoints):                       │
│   229 │   │   """                                                                                │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/geometry/coordinates/cartesian_coordinates.py:557 in        │
│ _oblique_pixelize                                                                                │
│                                                                                                  │
│   554 │   def _oblique_pixelize(self, data_source, field, bounds, size, antialias):              │
│   555 │   │   from yt.frontends.ytdata.data_structures import YTSpatialPlotDataset               │
│   556 │   │                                                                                      │
│ ❱ 557 │   │   indices = np.argsort(data_source["pdx"])[::-1].astype(np.int_)                     │
│   558 │   │   buff = np.full((size[1], size[0]), np.nan, dtype="float64")                        │
│   559 │   │   ftype = "index"                                                                    │
│   560 │   │   if isinstance(data_source.ds, YTSpatialPlotDataset):                               │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/data_objects/data_containers.py:259 in __getitem__          │
│                                                                                                  │
│    256 │   │   f = self._determine_fields([key])[0]                                              │
│    257 │   │   if f not in self.field_data and key not in self.field_data:                       │
│    258 │   │   │   if f in self._container_fields:                                               │
│ ❱  259 │   │   │   │   self.field_data[f] = self.ds.arr(self._generate_container_field(f))       │
│    260 │   │   │   │   return self.field_data[f]                                                 │
│    261 │   │   │   else:                                                                         │
│    262 │   │   │   │   self.get_data(f)                                                          │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/data_objects/selection_objects/slices.py:270 in             │
│ _generate_container_field                                                                        │
│                                                                                                  │
│   267 │   │   │   tr += z * self._norm_vec[2]                                                    │
│   268 │   │   │   return tr                                                                      │
│   269 │   │   elif field == "pdx":                                                               │
│ ❱ 270 │   │   │   return self._current_chunk.fwidth[:, 0] * 0.5                                  │
│   271 │   │   elif field == "pdy":                                                               │
│   272 │   │   │   return self._current_chunk.fwidth[:, 1] * 0.5                                  │
│   273 │   │   elif field == "pdz":                                                               │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/geometry/geometry_handler.py:257 in cacheable_func          │
│                                                                                                  │
│   254 │   │   if self._cache and getattr(self, n, None) is not None:                             │
│   255 │   │   │   return getattr(self, n)                                                        │
│   256 │   │   if self.data_size is None:                                                         │
│ ❱ 257 │   │   │   tr = self._accumulate_values(n[1:])                                            │
│   258 │   │   else:                                                                              │
│   259 │   │   │   tr = func(self)                                                                │
│   260 │   │   if self._cache:                                                                    │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/geometry/geometry_handler.py:293 in _accumulate_values      │
│                                                                                                  │
│   290 │   │   arrs = []                                                                          │
│   291 │   │   for obj in self._fast_index or self.objs:                                          │
│   292 │   │   │   f = getattr(obj, mname)                                                        │
│ ❱ 293 │   │   │   arrs.append(f(self.dobj))                                                      │
│   294 │   │   if method == "dtcoords":                                                           │
│   295 │   │   │   arrs = [arr[0] for arr in arrs]                                                │
│   296 │   │   elif method == "tcoords":                                                          │
│                                                                                                  │
│ /Users/robcleme/dev/yt-project/yt/yt/data_objects/index_subobjects/particle_container.py:17 in   │
│ _func_non_indexed                                                                                │
│                                                                                                  │
│    14                                                                                            │
│    15 def _non_indexed(name):                                                                    │
│    16 │   def _func_non_indexed(self, *args, **kwargs):                                          │
│ ❱  17 │   │   raise YTNonIndexedDataContainer(self)                                              │
│    18 │                                                                                          │
│    19 │   return _func_non_indexed                                                               │
│    20                                                                                            │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
YTNonIndexedDataContainer: The data container type (<class 'yt.data_objects.index_subobjects.particle_container.ParticleContainer'>)
is an unindexed type. Operations such as ires, icoords, fcoords and fwidth will not work on it.
```

</details>

**Expected outcome**

I very much agree with users (Christian van Der Merwe and Shulan Yan) that this error message isn't clear enough, and it takes too much expertise to decipher. The traceback is also very long, hinting that the error is triggered from the depths of the code base instead of close to the surface. We can probably craft a much clearer close to users.

I think documentation should also make it clear that off-axis operations have only limited support currently, to avoid deception and disappointment.



Discussion:
@neutrinoceros the issue name says off-axis projections, but these are supported. Other off-axis operations are not. 

@chummels you need SPH support for off-axis `Ray` objects, right? So that must exist--the issue with `LinePlot` is that we need to pixelize the particles
> @neutrinoceros the issue name says off-axis projections, but these are supported. Other off-axis operations are not.

Thank you very much for pointing it out, I've updated the title !

Resolving patch:
diff --git a/yt/utilities/exceptions.py b/yt/utilities/exceptions.py
--- a/yt/utilities/exceptions.py
+++ b/yt/utilities/exceptions.py
@@ -603,9 +603,13 @@ def __init__(self, cont):
         self.cont = cont
 
     def __str__(self):
+        class_name = self.cont.__class__.__name__
         return (
-            f"The data container type ({type(self.cont)}) is an unindexed type. "
-            "Operations such as ires, icoords, fcoords and fwidth will not work on it."
+            f"The data container type ({class_name}) is an unindexed type. "
+            "Operations such as ires, icoords, fcoords and fwidth will not work on it.\n"
+            "Did you just attempt to perform an off-axis operation ? "
+            "Be sure to consult the latest documentation to see whether the operation "
+            "you tried is actually supported for your data type."
         )
 
 


Test patch: