Repository: yt-project/yt

Issue:
`load_uniform_grid` fails when particle position is outside the bounding box
**Code for reproduction**

<!--A minimum code snippet required to reproduce the bug, also minimizing the
number of dependencies required.-->

<!-- If you need to use a data file to trigger the issue you're having, consider
using one of the datasets from the yt data hub (http://yt-project.org/data). If
your issue cannot be triggered using a public dataset, you can use the yt
curldrop (https://docs.hub.yt/services.html#curldrop) to share data
files. Please include a link to the dataset in the issue if you use the
curldrop.-->

```python
import yt, numpy as np

posx_arr = np.random.uniform(low=-1.6, high=1.5, size=10000)
posy_arr = np.random.uniform(low=-1.5, high=1.5, size=10000)
posz_arr = np.random.uniform(low=-1.5, high=1.5, size=10000)
data = dict(density = (np.random.random(size=(64,64,64)), "Msun/kpc**3"), 
            particle_position_x = (posx_arr, 'code_length'), 
            particle_position_y = (posy_arr, 'code_length'),
            particle_position_z = (posz_arr, 'code_length'))
bbox = np.array([[-1.5, 1.5], [-1.5, 1.5], [-1.5, 1.5]])
ds = yt.load_uniform_grid(data, data["density"][0].shape, length_unit=(1.0, "Mpc"), mass_unit=(1.0,"Msun"), 
                          bbox=bbox, nprocs=4)
```

**Actual outcome**

<!--The output produced by the above code, which may be a screenshot, console
output, etc.-->

```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-2-a9ff1fb7d916> in <module>()
      8 bbox = np.array([[-1.5, 1.5], [-1.5, 1.5], [-1.5, 1.5]])
      9 ds = yt.load_uniform_grid(data, data["density"][0].shape, length_unit=(1.0, "Mpc"), mass_unit=(1.0,"Msun"), 
---> 10                           bbox=bbox, nprocs=4)
     11 

~/anaconda3/envs/phcpy/lib/python3.6/site-packages/yt/frontends/stream/data_structures.py in load_uniform_grid(data, domain_dimensions, length_unit, bbox, nprocs, sim_time, mass_unit, time_unit, velocity_unit, magnetic_unit, periodicity, geometry, unit_system)
    752     if number_of_particles > 0:
    753         # This will update the stream handler too
--> 754         assign_particle_data(sds, pdata)
    755 
    756     return sds

~/anaconda3/envs/phcpy/lib/python3.6/site-packages/yt/frontends/stream/data_structures.py in assign_particle_data(ds, pdata)
    449             idxs = np.argsort(particle_grid_inds)
    450             particle_grid_count = np.bincount(particle_grid_inds.astype("intp"),
--> 451                                               minlength=num_grids)
    452             particle_indices = np.zeros(num_grids + 1, dtype='int64')
    453             if num_grids > 1:

ValueError: The first argument of bincount must be non-negative
```



Discussion:
Thanks for the excellent bug report! 🎆 

Resolving patch:
diff --git a/yt/frontends/stream/data_structures.py b/yt/frontends/stream/data_structures.py
--- a/yt/frontends/stream/data_structures.py
+++ b/yt/frontends/stream/data_structures.py
@@ -385,7 +385,7 @@ def set_particle_types(data):
             particle_types[key] = False
     return particle_types
 
-def assign_particle_data(ds, pdata):
+def assign_particle_data(ds, pdata, bbox):
 
     """
     Assign particle data to the grids using MatchPointsToGrids. This
@@ -446,6 +446,26 @@ def assign_particle_data(ds, pdata):
                     "Cannot decompose particle data without position fields!")
             pts = MatchPointsToGrids(grid_tree, len(x), x, y, z)
             particle_grid_inds = pts.find_points_in_tree()
+            assigned_particles, = (particle_grid_inds >= 0).nonzero()
+            num_particles = particle_grid_inds.size
+            num_unassigned = num_particles - assigned_particles.size
+            if num_unassigned > 0:
+                m = ("Discarding %s particles (out of %s) that are outside "
+                     "bounding box. ")
+                eps = np.finfo(x.dtype).eps
+                s = np.array([[x.min() - eps, x.max() + eps],
+                              [y.min() - eps, y.max() + eps],
+                              [z.min() - eps, z.max() + eps]])
+                sug_bbox = [
+                    [min(bbox[0, 0], s[0, 0]), max(bbox[0, 1], s[0, 1])],
+                    [min(bbox[1, 0], s[1, 0]), max(bbox[1, 1], s[1, 1])],
+                    [min(bbox[2, 0], s[2, 0]), max(bbox[2, 1], s[2, 1])]]
+                m += ("Set bbox=%s to avoid this in the future.")
+                mylog.warn(m % (num_unassigned, num_particles, sug_bbox))
+                particle_grid_inds = particle_grid_inds[assigned_particles]
+                x = x[assigned_particles]
+                y = y[assigned_particles]
+                z = z[assigned_particles]
             idxs = np.argsort(particle_grid_inds)
             particle_grid_count = np.bincount(particle_grid_inds.astype("intp"),
                                               minlength=num_grids)
@@ -751,7 +771,7 @@ def load_uniform_grid(data, domain_dimensions, length_unit=None, bbox=None,
     # Now figure out where the particles go
     if number_of_particles > 0:
         # This will update the stream handler too
-        assign_particle_data(sds, pdata)
+        assign_particle_data(sds, pdata, bbox)
 
     return sds
 
@@ -1043,7 +1063,7 @@ def refine_amr(base_ds, refinement_criteria, fluid_operators, max_level,
         # Now figure out where the particles go
         if number_of_particles > 0:
             # This will update the stream handler too
-            assign_particle_data(ds, pdata)
+            assign_particle_data(ds, pdata, bbox)
 
         cur_gc = ds.index.num_grids
 


Test patch:
diff --git a/yt/frontends/stream/tests/test_stream_particles.py b/yt/frontends/stream/tests/test_stream_particles.py
--- a/yt/frontends/stream/tests/test_stream_particles.py
+++ b/yt/frontends/stream/tests/test_stream_particles.py
@@ -304,3 +304,21 @@ def test_load_particles_types():
             npart += dd[ptype, "particle_position_%s" % ax].size
         assert npart == num_tot_particles
         assert dd["all", "particle_position_%s" % ax].size == num_tot_particles
+
+def test_particles_outside_domain():
+    np.random.seed(0x4d3d3d3)
+    posx_arr = np.random.uniform(low=-1.6, high=1.5, size=1000)
+    posy_arr = np.random.uniform(low=-1.5, high=1.5, size=1000)
+    posz_arr = np.random.uniform(low=-1.5, high=1.5, size=1000)
+    dens_arr = np.random.random((16, 16, 16))
+    data = dict(
+        density=dens_arr,
+        particle_position_x=posx_arr,
+        particle_position_y=posy_arr,
+        particle_position_z=posz_arr)
+    bbox = np.array([[-1.5, 1.5], [-1.5, 1.5], [-1.5, 1.5]])
+    ds = load_uniform_grid(data, (16, 16, 16), bbox=bbox, nprocs=4)
+    wh = (posx_arr < bbox[0, 0]).nonzero()[0]
+    assert wh.size == 1000 - ds.particle_type_counts['io']
+    ad = ds.all_data()
+    assert ds.particle_type_counts['io'] == ad['particle_position_x'].size