Repository: yt-project/yt

Issue:
max_level and min_level should be descriptors
<!--A short 1-2 sentences that succinctly describes the bug-->


The `max_level` and `min_level` attribute of data objects are python attributes that can be optionally set by users to control the maximum and minimum AMR level to sample at:

```
ad = ds.all_data()
ad.max_level = 2
ad['grid_level'].max()
```

Even though the full AMR dataset might contain data beyond level 2, this option only returns the data at level 2 or below. Critically, it also alters how yt creates child grid masks such that data that would normally be masked out on low refinement levels will be visible. This creates an effective maximum refinement level that can be useful, particularly for deep AMR hierarchies or certain analysis tasks.

Right now `max_level` and `min_level` only appear in the low-level cython selection machinery and get handled by the base cython selector class:

https://github.com/yt-project/yt/blob/2a0e17ad25353790ea6d707c4e56021612d63ca3/yt/geometry/selection_routines.pyx#L113-L120

This implementation is nice in that it is simple but it has a few issues. In particular, since the selector object is cached by the data object once it is created, the following example will not work as expected:

```
import yt
ds = yt.load('IsolatedGalaxy/galaxy0030/galaxy0030)
ad = ds.all_data()
ad['density']
ad.max_level = 2
ad['grid_level'].max()
```

Currently this prints 8.

The fix is to add `max_level` and `min_level` as descriptors defined as attributes of the `YTDataContainer` class. The descriptor's setter needs a callback that invalidates the selector if `max_level` or `min_level` changes so that the next data object access will create a new selector object.

While we're at it, we should probably also improve the documentation for this feature, for example by adding a cookbook recipe showing how to downsample a data object access, a `SlicePlot` (by setting `max_level` on the underlying `YTSlice`), and a `ProjectionPlot`.


Discussion:
If anyone wants to take this on and doesn't know what a descriptor is, I think Chris Beaumont's tutorial is the best summary I know: http://nbviewer.jupyter.org/urls/gist.github.com/ChrisBeaumont/5758381/raw/descriptor_writeup.ipynb

Resolving patch:
diff --git a/doc/source/cookbook/downsampling_amr.py b/doc/source/cookbook/downsampling_amr.py
new file mode 100644
--- /dev/null
+++ b/doc/source/cookbook/downsampling_amr.py
@@ -0,0 +1,28 @@
+import yt
+
+ds = yt.load('IsolatedGalaxy/galaxy0030/galaxy0030')
+
+# The maximum refinement level of this dataset is 8
+print(ds.max_level)
+
+# If we ask for *all* of the AMR data, we get back field
+# values sampled at about 3.6 million AMR zones
+ad = ds.all_data()
+print(ad['gas', 'density'].shape)
+
+# Let's only sample data up to AMR level 2
+ad.max_level = 2
+
+# Now we only sample from about 200,000 zones
+print(ad['gas', 'density'].shape)
+
+# Note that this includes data at level 2 that would
+# normally be masked out. There aren't any "holes" in
+# the downsampled AMR mesh, the volume still sums to
+# the volume of the domain:
+print(ad['index', 'cell_volume'].sum())
+print(ds.domain_width.prod())
+
+# Now let's make a downsampled plot
+plot = yt.SlicePlot(ds, 'z', ('gas', 'density'), data_source=ad)
+plot.save('downsampled.png')
diff --git a/yt/data_objects/data_containers.py b/yt/data_objects/data_containers.py
--- a/yt/data_objects/data_containers.py
+++ b/yt/data_objects/data_containers.py
@@ -1186,6 +1186,8 @@ class YTSelectionContainer(YTDataContainer, ParallelAnalysisInterface):
     _current_chunk = None
     _data_source = None
     _dimensionality = None
+    _max_level = None
+    _min_level = None
 
     def __init__(self, ds, field_parameters, data_source=None):
         ParallelAnalysisInterface.__init__(self)
@@ -1204,7 +1206,8 @@ def __init__(self, ds, field_parameters, data_source=None):
 
     @property
     def selector(self):
-        if self._selector is not None: return self._selector
+        if self._selector is not None:
+            return self._selector
         s_module = getattr(self, '_selector_module',
                            yt.geometry.selection_routines)
         sclass = getattr(s_module,
@@ -1498,6 +1501,46 @@ def fcoords_vertex(self):
             self.index._identify_base_chunk(self)
         return self._current_chunk.fcoords_vertex
 
+    @property
+    def max_level(self):
+        if self._max_level is None:
+            try:
+                return self.ds.max_level
+            except AttributeError:
+                return None
+        return self._max_level
+
+    @max_level.setter
+    def max_level(self, value):
+        if self._selector is not None:
+            del self._selector
+            self._selector = None
+        self._current_chunk = None
+        self.size = None
+        self.shape = None
+        self.field_data.clear()
+        self._max_level = value
+
+    @property
+    def min_level(self):
+        if self._min_level is None:
+            try:
+                return 0
+            except AttributeError:
+                return None
+        return self._min_level
+
+    @min_level.setter
+    def min_level(self, value):
+        if self._selector is not None:
+            del self._selector
+            self._selector = None
+        self.field_data.clear()
+        self.size = None
+        self.shape = None
+        self._current_chunk = None
+        self._min_level = value
+
 class YTSelectionContainer0D(YTSelectionContainer):
     _spatial = False
     _dimensionality = 0


Test patch:
diff --git a/yt/data_objects/tests/test_compose.py b/yt/data_objects/tests/test_compose.py
--- a/yt/data_objects/tests/test_compose.py
+++ b/yt/data_objects/tests/test_compose.py
@@ -1,8 +1,9 @@
 import numpy as np
 
 from yt.testing import \
-    fake_random_ds, \
-    assert_array_equal
+    assert_array_equal, \
+    fake_amr_ds, \
+    fake_random_ds
 from yt.units.yt_array import \
     YTArray, \
     uintersect1d
@@ -147,3 +148,13 @@ def test_compose_overlap():
             id3 = data3['index', 'ID']
             id3.sort()
             assert_array_equal(uintersect1d(id1, id2), id3)
+
+def test_compose_max_level_min_level():
+    ds = fake_amr_ds()
+    ad = ds.all_data()
+    ad.max_level = 2
+    slc = ds.slice('x', 0.5, data_source=ad)
+    assert slc['grid_level'].max() == 2
+    frb = slc.to_frb(1.0, 128)
+    assert np.all(frb['Density'] > 0)
+    assert frb['grid_level'].max() == 2
diff --git a/yt/data_objects/tests/test_regions.py b/yt/data_objects/tests/test_regions.py
--- a/yt/data_objects/tests/test_regions.py
+++ b/yt/data_objects/tests/test_regions.py
@@ -1,5 +1,6 @@
 from yt.testing import \
     assert_array_equal, \
+    fake_amr_ds, \
     fake_random_ds
 from yt.units import cm
 
@@ -15,3 +16,16 @@ def test_box_creation():
     dens_no_units = reg['density']
 
     assert_array_equal(dens_units, dens_no_units)
+
+def test_max_level_min_level_semantics():
+    ds = fake_amr_ds()
+    ad = ds.all_data()
+    assert ad['grid_level'].max() == 4
+    ad.max_level = 2
+    assert ad['grid_level'].max() == 2
+    ad.max_level = 8
+    assert ad['grid_level'].max() == 4
+    ad.min_level = 2
+    assert ad['grid_level'].min() == 2
+    ad.min_level = 0
+    assert ad['grid_level'].min() == 0