예제 #1
0
파일: nodes.py 프로젝트: NelisW/RBF
def poisson_disc_nodes(radius, domain,
                       ntests=50,
                       rmax_factor=1.5,
                       build_rtree=False,
                       **kwargs):
    '''
    Generates nodes within a two or three dimensional domain. This first
    generate nodes with Poisson disc sampling, and then the nodes are dispersed
    to ensure a more even distribution. This function is considerably slower
    than `min_energy_nodes` but it has the advantage of directly specifying the
    node spacing.

    Parameters
    ----------
    radius : float or callable
        The radius for each disc. This is the minimum allowable distance
        between the nodes generated by Poisson disc sampling. This can be a
        float or a function that takes a (n, d) array of locations and returns
        an (n,) array of disc radii.

    domain : (p, d) float array and (q, d) int array
        Vertices of the domain and connectivity of the vertices

    build_rtree : bool, optional
        If `True`, then an R-tree will be built to speed up computational
        geometry operations. This should be set to `True` if there are many
        (>10,000) simplices making up the domain

    **kwargs
        Additional arguments passed to `prepare_nodes`

    Returns
    -------
    (n, d) float array
        Nodes positions

    dict
        The indices of nodes belonging to each group. There will always be a
        group called 'interior' containing the nodes that are not on the
        boundary. By default there is a group containing all the boundary nodes
        called 'boundary:all'. If `boundary_groups` was specified, then those
        groups will be included in this dictionary and their names will be
        given a 'boundary:' prefix. If `boundary_groups_with_ghosts` was
        specified then those groups of ghost nodes will be included in this
        dictionary and their names will be given a 'ghosts:' prefix.

    (n, d) float array
        Outward normal vectors for each node. If a node is not on the boundary
        then its corresponding row will contain NaNs.

    Notes
    -----
    It is assumed that `vert` and `smp` define a closed domain. If this is not
    the case, then it is likely that an error message will be raised which says
    "ValueError: No intersection found for segment

    '''
    domain = as_domain(domain)
    if build_rtree:
        logger.debug('Building R-tree...')
        domain.build_rtree()
        logger.debug('Done')

    if np.isscalar(radius):
        scalar_radius = radius
        def radius(x):
            return np.full(x.shape[0], scalar_radius)

    def rho(x):
        # the density function corresponding to the radius function
        return 1.0/(radius(x)**x.shape[1])

    nodes = poisson_discs(
        radius, domain,
        ntests=ntests,
        rmax_factor=rmax_factor
        )

    out = prepare_nodes(nodes, domain, rho=rho, **kwargs)
    return out
예제 #2
0
파일: nodes.py 프로젝트: NelisW/RBF
def disperse(nodes, domain,
             iterations=20,
             rho=None,
             fixed_nodes=None,
             neighbors=None,
             delta=0.1):
    '''
    Disperses the nodes within the domain. The dispersion is analogous to
    electrostatic repulsion, where neighboring nodes exert a repulsive force on
    eachother. Each node steps in the direction of its net repulsive force with
    a step size proportional to the distance to its nearest neighbor. If a node
    is repelled into a boundary then it bounces back in.

    Parameters
    ----------
    nodes : (n, d) float array
        Initial node positions

    domain : (p, d) float array and (q, d) int array
        Vertices of the domain and connectivity of the vertices.

    iterations : int, optional
        Number of dispersion iterations.

    rho : callable, optional
        Takes an (n, d) array as input and returns the repulsion force for a
        node at those position.

    fixed_nodes : (k, d) float array, optional
        Nodes which do not move and only provide a repulsion force.

    neighbors : int, optional
        The number of adjacent nodes used to determine repulsion forces for
        each node.

    delta : float, optional
        The step size. Each node moves in the direction of the repulsion force
        by a distance `delta` times the distance to the nearest neighbor.

    Returns
    -------
    (n, d) float array

    '''
    domain = as_domain(domain)
    nodes = np.asarray(nodes, dtype=float)
    assert_shape(nodes, (None, domain.dim), 'nodes')

    if rho is None:
        def rho(x):
            return np.ones(x.shape[0])

    if fixed_nodes is None:
        fixed_nodes = np.zeros((0, domain.dim), dtype=float)
    else:
        fixed_nodes = np.asarray(fixed_nodes)
        assert_shape(fixed_nodes, (None, domain.dim), 'fixed_nodes')

    if neighbors is None:
        # the default number of neighboring nodes to use when computing the
        # repulsion force is 3 for 2D and 4 for 3D
        if domain.dim == 2:
            neighbors = 3

        elif domain.dim == 3:
            neighbors = 4

    # ensure that the number of neighboring nodes used for the repulsion force
    # is less than or equal to the total number of nodes
    neighbors = min(neighbors, nodes.shape[0] + fixed_nodes.shape[0] - 1)

    for itr in range(iterations):
        logger.debug(
            'Starting node dispersion iterations %s of %s.'
            % (itr + 1, iterations)
            )

        new_nodes = _disperse_step(nodes, rho, fixed_nodes, neighbors, delta)
        # If the line segment connecting the new and old node crosses the
        # boundary, then the node should bounce off the boundary.
        crossed, = domain.intersection_count(nodes, new_nodes).nonzero()
        # points where nodes intersected the boundary and the simplex they
        # intersected at
        intr_pnt, intr_idx = domain.intersection_point(
            nodes[crossed],
            new_nodes[crossed]
            )

        # normal vector to the intersection points
        intr_norms = domain.normals[intr_idx]
        # residual distance that the nodes wanted to travel beyond the boundary
        res = new_nodes[crossed] - intr_pnt
        # normal component of the residuals
        res_perp = np.sum(res*intr_norms, axis=1)
        # bounce nodes off the boundary
        new_nodes[crossed] -= 2*intr_norms*res_perp[:, None]
        # check to see if the bounced nodes are still crossing the boundary. If
        # they are, then set them back to their original position. Do not
        # bother with multiple bounces.
        still_crossed, = domain.intersection_count(
            nodes[crossed],
            new_nodes[crossed]
            ).nonzero()

        new_nodes[crossed[still_crossed]] = nodes[crossed[still_crossed]]
        nodes = new_nodes

    return nodes
예제 #3
0
파일: nodes.py 프로젝트: NelisW/RBF
def min_energy_nodes(n, domain,
                     rho=None,
                     build_rtree=False,
                     start=0,
                     **kwargs):
    '''
    Generates nodes within a two or three dimensional. This first generates
    nodes with a rejection sampling algorithm, and then the nodes are dispersed
    to ensure a more even distribution.

    Parameters
    ----------
    n : int
        The number of nodes generated during rejection sampling. This is not
        necessarily equal to the number of nodes returned.

    domain : (p, d) float array and (q, d) int array
        Vertices of the domain and connectivity of the vertices

    rho : function, optional
        Node density function. Takes a (n, d) array of coordinates and returns
        an (n,) array of desired node densities at those coordinates. This
        function should be normalized to be between 0 and 1.

    build_rtree : bool, optional
        If `True`, then an R-tree will be built to speed up computational
        geometry operations. This should be set to `True` if there are many
        (>10,000) simplices making up the domain.

    start : int, optional
        The starting index for the Halton sequence, which is used to propose
        new points. Setting this value is akin to setting the seed for a random
        number generator.

    **kwargs
        Additional arguments passed to `prepare_nodes`

    Returns
    -------
    (n, d) float array
        Nodes positions

    dict
        The indices of nodes belonging to each group. There will always be a
        group called 'interior' containing the nodes that are not on the
        boundary. By default there is a group containing all the boundary nodes
        called 'boundary:all'. If `boundary_groups` was specified, then those
        groups will be included in this dictionary and their names will be
        given a 'boundary:' prefix. If `boundary_groups_with_ghosts` was
        specified then those groups of ghost nodes will be included in this
        dictionary and their names will be given a 'ghosts:' prefix.

    (n, d) float array
        Outward normal vectors for each node. If a node is not on the boundary
        then its corresponding row will contain NaNs.

    Notes
    -----
    It is assumed that `vert` and `smp` define a closed domain. If this is not
    the case, then it is likely that an error message will be raised which says
    "ValueError: No intersection found for segment

    Examples
    --------
    make 9 nodes within the unit square

    >>> vert = np.array([[0, 0], [1, 0], [1, 1], [0, 1]])
    >>> smp = np.array([[0, 1], [1, 2], [2, 3], [3, 0]])
    >>> out = min_energy_nodes(9, (vert, smp))

    view the nodes

    >>> out[0]
    array([[ 0.50325675,  0.        ],
           [ 0.00605261,  1.        ],
           [ 1.        ,  0.51585247],
           [ 0.        ,  0.00956821],
           [ 1.        ,  0.99597894],
           [ 0.        ,  0.5026365 ],
           [ 1.        ,  0.00951112],
           [ 0.48867638,  1.        ],
           [ 0.54063894,  0.47960892]])

    view the indices of nodes making each group

    >>> out[1]
    {'boundary:all': array([7, 6, 5, 4, 3, 2, 1, 0]),
     'interior': array([8])}

    view the outward normal vectors for each node, note that the normal vector
    for the interior node is `nan`

    >>> out[2]
    array([[  0.,  -1.],
           [  0.,   1.],
           [  1.,  -0.],
           [ -1.,  -0.],
           [  1.,  -0.],
           [ -1.,  -0.],
           [  1.,  -0.],
           [  0.,   1.],
           [ nan,  nan]])

    '''
    domain = as_domain(domain)
    if build_rtree:
        logger.debug('Building R-tree...')
        domain.build_rtree()
        logger.debug('Done')

    if rho is None:
        def rho(x):
            return np.ones(x.shape[0])

    nodes = rejection_sampling(n, rho, domain, start=start)
    out = prepare_nodes(nodes, domain, rho=rho, **kwargs)
    return out
예제 #4
0
파일: nodes.py 프로젝트: NelisW/RBF
def prepare_nodes(nodes, domain,
                  rho=None,
                  iterations=20,
                  neighbors=None,
                  dispersion_delta=0.1,
                  pinned_nodes=None,
                  snap_delta=0.5,
                  boundary_groups=None,
                  boundary_groups_with_ghosts=None,
                  ghost_delta=0.5,
                  include_vertices=False,
                  orient_simplices=True):
    '''
    Prepares a set of nodes for solving PDEs with the RBF and RBF-FD method.
    This includes: dispersing the nodes away from eachother to ensure a more
    even spacing, snapping nodes to the boundary, determining the normal
    vectors for each node, determining the group that each node belongs to,
    creating ghost nodes, sorting the nodes so that adjacent nodes are close in
    memory, and verifying that no two nodes are anomalously close to eachother.

    The function returns a set of nodes, the normal vectors for each node, and
    a dictionary identifying which group each node belongs to.

    Parameters
    ----------
    nodes : (n, d) float arrary
        An initial sampling of nodes within the domain

    domain : (p, d) float array and (q, d) int array
        Vertices of the domain and connectivity of the vertices

    rho : function, optional
        Node density function. Takes a (n, d) array of coordinates and returns
        an (n,) array of desired node densities at those coordinates. This is
        used during the node dispersion step.

    iterations : int, optional
        Number of dispersion iterations.

    neighbors : int, optional
        Number of neighboring nodes to use when calculating the repulsion
        force. This defaults to 3 for 2D nodes and 4 for 3D nodes.

    dispersion_delta : float, optional
        Scaling factor for the node step size in each iteration. The step size
        is equal to `dispersion_delta` times the distance to the nearest
        neighbor.

    pinned_nodes : (k, d) array, optional
        Nodes which do not move and only provide a repulsion force. These nodes
        are included in the set of nodes returned by this function and they are
        in the group named "pinned".

    snap_delta : float, optional
        Controls the maximum snapping distance. The maximum snapping distance
        for each node is `snap_delta` times the distance to the nearest
        neighbor. This defaults to 0.5.

    boundary_groups: dict, optional
        Dictionary defining the boundary groups. The keys are the names of the
        groups and the values are lists of simplex indices making up each
        group. This function will return a dictionary identifying which nodes
        belong to each boundary group. By default, there is a single group
        named 'all' for the entire boundary. Specifically, The default value is
        `{'all':range(len(smp))}`.

    boundary_groups_with_ghosts: list of strs, optional
        List of boundary groups that will be given ghost nodes. By default, no
        boundary groups are given ghost nodes. The groups specified here must
        exist in `boundary_groups`.

    ghost_delta : float, optional
        How far the ghost nodes should be from their corresponding boundary
        node. The distance is `ghost_delta` times the distance to the nearest
        neighbor.

    include_vertices : bool, optional
        If `True`, then the vertices will be included in the output nodes. Each
        vertex will be assigned to the boundary group that its adjoining
        simplices are part of. If the simplices are in multiple groups, then
        the vertex will be assigned to the group containing the simplex that
        comes first in `smp`.

    orient_simplices : bool, optional
        If `False` then it is assumed that the simplices are already oriented
        such that their normal vectors point outward.

    Returns
    -------
    (m, d) float array
        Nodes positions

    dict
        The indices of nodes belonging to each group. There will always be a
        group called 'interior' containing the nodes that are not on the
        boundary. By default there is a group containing all the boundary nodes
        called 'boundary:all'. If `boundary_groups` was specified, then those
        groups will be included in this dictionary and their names will be
        given a 'boundary:' prefix. If `boundary_groups_with_ghosts` was
        specified then those groups of ghost nodes will be included in this
        dictionary and their names will be given a 'ghosts:' prefix.

    (n, d) float array
        Outward normal vectors for each node. If a node is not on the boundary
        then its corresponding row will contain NaNs.

    '''
    domain = as_domain(domain)
    if orient_simplices:
        logger.debug('Orienting simplices...')
        domain.orient_simplices()
        logger.debug('Done')

    nodes = np.asarray(nodes, dtype=float)
    assert_shape(nodes, (None, domain.dim), 'nodes')

    # the `fixed_nodes` are used to provide a repulsion force during
    # dispersion, but they do not move.
    fixed_nodes = np.zeros((0, domain.dim), dtype=float)
    if pinned_nodes is not None:
        pinned_nodes = np.asarray(pinned_nodes, dtype=float)
        assert_shape(pinned_nodes, (None, domain.dim), 'pinned_nodes')
        fixed_nodes = np.vstack((fixed_nodes, pinned_nodes))

    if include_vertices:
        fixed_nodes = np.vstack((fixed_nodes, domain.vertices))

    logger.debug('Dispersing nodes...')
    nodes = disperse(
        nodes, domain,
        iterations=iterations,
        rho=rho,
        fixed_nodes=fixed_nodes,
        neighbors=neighbors,
        delta=dispersion_delta
        )

    logger.debug('Done')

    # append the domain vertices to the collection of nodes if requested
    if include_vertices:
        nodes = np.vstack((nodes, domain.vertices))

    # snap nodes to the boundary, identifying which simplex each node
    # was snapped to
    logger.debug('Snapping nodes to boundary...')
    nodes, smpid = domain.snap(nodes, delta=snap_delta)
    logger.debug('Done')

    normals = np.full_like(nodes, np.nan)
    normals[smpid >= 0] = domain.normals[smpid[smpid >= 0]]

    # create a dictionary identifying which nodes belong to which group
    groups = {}
    groups['interior'], = (smpid == -1).nonzero()

    # append the user specified pinned nodes
    if pinned_nodes is not None:
        pinned_idx = np.arange(pinned_nodes.shape[0]) + nodes.shape[0]
        pinned_normals = np.full_like(pinned_nodes, np.nan)
        nodes = np.vstack((nodes, pinned_nodes))
        normals = np.vstack((normals, pinned_normals))
        groups['pinned'] = pinned_idx


    logger.debug('Grouping boundary nodes...')
    if boundary_groups is None:
        boundary_groups = {'all': np.arange(len(domain.simplices))}
    else:
        boundary_groups = {
            str(k): np.array(v, dtype=int) for k, v in boundary_groups.items()
            }

        # Validate the user-specified boundary groups
        simplex_counts = Counter(chain(*boundary_groups.values()))
        for idx in range(len(domain.simplices)):
            if simplex_counts[idx] != 1:
                logger.warning(
                    'Simplex %s is specified %s times in the boundary groups.'
                     % (idx, simplex_counts[idx])
                     )

        extra = set(simplex_counts).difference(range(len(domain.simplices)))
        if extra:
            raise ValueError(
                'The simplex indices %s were specified in the boundary groups '
                'but do not exist.' % extra
                )

    if boundary_groups_with_ghosts is None:
        boundary_groups_with_ghosts = []

    # find the mapping from simplex indices to node indices, then use
    # `boundary_groups` to find which nodes belong to each boundary group
    smp_to_nodes = [[] for _ in range(len(domain.simplices))]
    for i, j in enumerate(smpid):
        if j != -1:
            smp_to_nodes[j].append(i)

    for bnd_name, bnd_smp in boundary_groups.items():
        bnd_idx = list(chain.from_iterable(smp_to_nodes[i] for i in bnd_smp))
        groups['boundary:%s' % bnd_name] = np.array(bnd_idx, dtype=int)

    logger.debug('Done')

    logger.debug('Creating ghost nodes...')
    tree = KDTree(nodes)
    for bnd_name in boundary_groups_with_ghosts:
        bnd_idx = groups['boundary:%s' % bnd_name]
        spacing = ghost_delta*tree.query(nodes[bnd_idx], 2)[0][:, 1]
        ghost_idx = np.arange(bnd_idx.shape[0]) + nodes.shape[0]
        ghost_nodes = nodes[bnd_idx] + spacing[:, None]*normals[bnd_idx]
        ghost_normals = np.full_like(ghost_nodes, np.nan)
        nodes = np.vstack((nodes, ghost_nodes))
        normals = np.vstack((normals, ghost_normals))
        groups['ghosts:%s' % bnd_name] = ghost_idx

    logger.debug('Done')

    logger.debug('Sorting nodes...')
    sort_idx = neighbor_argsort(nodes)
    nodes = nodes[sort_idx]
    normals = normals[sort_idx]
    reverse_sort_idx = np.argsort(sort_idx)
    groups = {k: reverse_sort_idx[v] for k, v in groups.items()}
    logger.debug('Done')


    logger.debug('Checking the quality of the generated nodes...')
    _check_spacing(nodes, rho)
    logger.debug('Done')

    return nodes, groups, normals
예제 #5
0
파일: nodes.py 프로젝트: soltesz-lab/RBF
def disperse(nodes,
             domain,
             rho=None,
             fixed_nodes=None,
             neighbors=None,
             delta=0.1):
  '''
  Slightly disperses the nodes within the domain. The disperson is
  analogous to electrostatic repulsion, where neighboring node exert a
  repulsive force on eachother. If a node is repelled into a boundary
  then it bounces back in.

  Parameters
  ----------
  nodes : (n, d) float array
    Initial node positions

  domain : (p, d) float array and (q, d) int array
    Vertices of the domain and connectivity of the vertices.
  
  rho : callable, optional
    Takes an (n, d) array as input and returns the repulsion force for
    a node at those position. 

  fixed_nodes : (k, d) float array, optional
    Nodes which do not move and only provide a repulsion force

  neighbors : int, optional
    The number of adjacent nodes used to determine repulsion forces
    for each node
                 
  delta : float, optional
    The step size. Each node moves in the direction of the repulsion
    force by a distance `delta` times the distance to the nearest
    neighbor.

  Returns
  -------
  (n, d) float array
  
  '''
  domain = as_domain(domain)
  nodes = np.asarray(nodes, dtype=float)
  assert_shape(nodes, (None, domain.dim), 'nodes')

  if rho is None:
    def rho(x): 
      return np.ones(x.shape[0])

  if fixed_nodes is None:
    fixed_nodes = np.zeros((0, domain.dim), dtype=float)
  else:
    fixed_nodes = np.asarray(fixed_nodes)
    assert_shape(fixed_nodes, (None, domain.dim), 'fixed_nodes')

  if neighbors is None:
    # the default number of neighboring nodes to use when computing
    # the repulsion force is 4 for 2D and 5 for 3D
    if domain.dim == 2:
      neighbors = 4

    elif domain.dim == 3:
      neighbors = 5

  # ensure that the number of neighboring nodes used for the repulsion
  # force is less than or equal to the total number of nodes
  neighbors = min(neighbors, nodes.shape[0] + fixed_nodes.shape[0])
  # if m is 0 or 1 then the nodes remain stationary
  if neighbors <= 1:
    return np.array(nodes, copy=True)

  # node positions after repulsion
  out = _disperse(nodes, rho, fixed_nodes, neighbors, delta)
  # indices of nodes which are now outside the domain
  crossed = domain.intersection_count(nodes, out) > 0
  crossed, = crossed.nonzero()
  # points where nodes intersected the boundary and the simplex they
  # intersected at
  intr_pnt, intr_idx = domain.intersection_point(nodes[crossed], 
                                                 out[crossed])
  # normal vector to intersection points
  intr_norms = domain.normals[intr_idx]
  # distance that the node wanted to travel beyond the boundary
  res = out[crossed] - intr_pnt
  # bounce node off the boundary
  out[crossed] -= 2*intr_norms*np.sum(res*intr_norms, 1)[:, None]
  # check to see if the bounced nodes still intersect the boundary. If
  # they do, then set them back to their original position
  still_crossed = domain.intersection_count(nodes[crossed], 
                                            out[crossed]) > 0
  out[crossed[still_crossed]] = nodes[crossed[still_crossed]]
  return out
예제 #6
0
파일: nodes.py 프로젝트: soltesz-lab/RBF
def prepare_nodes(nodes, domain,
                  rho=None,
                  iterations=20,
                  neighbors=None,
                  dispersion_delta=0.1,
                  pinned_nodes=None,
                  snap_delta=0.5,
                  boundary_groups=None,
                  boundary_groups_with_ghosts=None,
                  include_vertices=False,
                  orient_simplices=True):
  '''
  Prepares a set of nodes for solving PDEs with the RBF and RBF-FD
  method. This includes: dispersing the nodes away from eachother to
  ensure a more even spacing, snapping nodes to the boundary,
  determining the normal vectors for each node, determining the group
  that each node belongs to, creating ghost nodes, sorting the nodes
  so that adjacent nodes are close in memory, and verifying that no
  two nodes are anomalously close to eachother.

  The function returns a set of nodes, the normal vectors for each
  node, and a dictionary identifying which group each node belongs to.

  Parameters
  ----------
  nodes : (n, d) float arrary
    An initial sampling of nodes within the domain

  domain : (p, d) float array and (q, d) int array
    Vertices of the domain and connectivity of the vertices

  rho : function, optional 
    Node density function. Takes a (n, d) array of coordinates and
    returns an (n,) array of desired node densities at those
    coordinates. This is used during the node dispersion step.

  iterations : int, optional
    Number of dispersion iterations.

  neighbors : int, optional
    Number of neighboring nodes to use when calculating the repulsion
    force. This defaults to 4 for 2D nodes and 5 for 3D nodes.

  dispersion_delta : float, optional
    Scaling factor for the node step size in each iteration. The step
    size is equal to `dispersion_delta` times the distance to the
    nearest neighbor.

  pinned_nodes : (k, d) array, optional
    Nodes which do not move and only provide a repulsion force. These
    nodes are included in the set of nodes returned by this function
    and they are in the group named "pinned".

  snap_delta : float, optional
    Controls the maximum snapping distance. The maximum snapping
    distance for each node is `snap_delta` times the distance to the
    nearest neighbor. This defaults to 0.5.

  boundary_groups: dict, optional 
    Dictionary defining the boundary groups. The keys are the names of
    the groups and the values are lists of simplex indices making up
    each group. This function will return a dictionary identifying
    which nodes belong to each boundary group. By default, there is a
    single group named 'all' for the entire boundary. Specifically,
    The default value is `{'all':range(len(smp))}`.

  boundary_groups_with_ghosts: list of strs, optional
    List of boundary groups that will be given ghost nodes. By
    default, no boundary groups are given ghost nodes. The groups
    specified here must exist in `boundary_groups`.

  include_vertices : bool, optional
    If `True`, then the vertices will be included in the output nodes.
    Each vertex will be assigned to the boundary group that its
    adjoining simplices are part of. If the simplices are in multiple
    groups, then the vertex will be assigned to the group containing
    the simplex that comes first in `smp`.

  orient_simplices : bool, optional
    If `False` then it is assumed that the simplices are already
    oriented such that their normal vectors point outward.
        
  Returns
  -------
  (m, d) float array
    Nodes positions

  dict 
    The indices of nodes belonging to each group. There will always be
    a group called 'interior' containing the nodes that are not on the
    boundary. By default there is a group containing all the boundary
    nodes called 'boundary:all'. If `boundary_groups` was specified,
    then those groups will be included in this dictionary and their
    names will be given a 'boundary:' prefix. If
    `boundary_groups_with_ghosts` was specified then those groups of
    ghost nodes will be included in this dictionary and their names
    will be given a 'ghosts:' prefix.
    
  (n, d) float array
    Outward normal vectors for each node. If a node is not on the
    boundary then its corresponding row will contain NaNs.

  '''
  domain = as_domain(domain)
  nodes = np.asarray(nodes, dtype=float)
  assert_shape(nodes, (None, domain.dim), 'nodes')

  # the `fixed_nodes` are used to provide a repulsion force during
  # dispersion, but they do not move. TODO There is chance that one of
  # the points in `fixed_nodes` is equal to a point in `nodes`. This
  # situation should be handled
  fixed_nodes = np.zeros((0, domain.dim), dtype=float)     
  if pinned_nodes is not None:
    pinned_nodes = np.asarray(pinned_nodes, dtype=float)
    assert_shape(pinned_nodes, (None, domain.dim), 'pinned_nodes')
    fixed_nodes = np.vstack((fixed_nodes, pinned_nodes))

  if include_vertices:
    fixed_nodes = np.vstack((fixed_nodes, domain.vertices))

  for i in range(iterations):
    logger.debug('starting node dispersion iterations %s of %s' 
                 % (i + 1, iterations))
    nodes = disperse(nodes, domain,
                     rho=rho, 
                     fixed_nodes=fixed_nodes, 
                     neighbors=neighbors, 
                     delta=dispersion_delta)

  # append the domain vertices to the collection of nodes if requested
  if include_vertices:
    nodes = np.vstack((nodes, domain.vertices))
    
  # snap nodes to the boundary, identifying which simplex each node
  # was snapped to
  logger.debug('snapping nodes to boundary ...')
  nodes, smpid = domain.snap(nodes, delta=snap_delta)
  logger.debug('done')

  # get the normal vectors for the boundary nodes    
  if orient_simplices:
    logger.debug('orienting simplices ...')
    domain.orient_simplices()
    logger.debug('done')
    
  normals = np.full_like(nodes, np.nan)
  normals[smpid >= 0] = domain.normals[smpid[smpid >= 0]]
  
  # create a dictionary identifying which nodes belong to which group
  groups = {}
  groups['interior'], = (smpid == -1).nonzero()

  # append the user specified pinned nodes
  if pinned_nodes is not None:
    pinned_idx = np.arange(pinned_nodes.shape[0]) + nodes.shape[0]
    pinned_normals = np.full_like(pinned_nodes, np.nan)
    nodes = np.vstack((nodes, pinned_nodes))
    normals = np.vstack((normals, pinned_normals))
    groups['pinned'] = pinned_idx
    
  if boundary_groups is None:
    boundary_groups = {'all': range(len(domain.simplices))}

  # TODO: There should be a test to make sure each simplex belongs to
  # at most one group.
  if boundary_groups_with_ghosts is None:
    boundary_groups_with_ghosts = []    

  # create groups for the boundary nodes
  logger.debug('grouping boundary nodes and generating ghosts ...')
  for k, v in boundary_groups.items():
    # convert the list of simplices in the boundary group to a set,
    # because it is much faster to determine membership of a set
    v = set(v)
    bnd_idx = np.array([i for i, j in enumerate(smpid) if j in v])
    groups['boundary:' + k] = bnd_idx
    if k in boundary_groups_with_ghosts:
      # append ghost nodes if requested
      dist = KDTree(nodes).query(nodes[bnd_idx], 2)[0][:, [1]]
      ghost_idx = np.arange(bnd_idx.shape[0]) + nodes.shape[0]         
      ghost_nodes = nodes[bnd_idx] + 0.5*dist*normals[bnd_idx]
      ghost_normals = np.full_like(ghost_nodes, np.nan)
      nodes = np.vstack((nodes, ghost_nodes))
      normals = np.vstack((normals, ghost_normals))
      groups['ghosts:' + k] = ghost_idx
  
  logger.debug('done')
  
  # sort `nodes` so that spatially adjacent nodes are close together
  logger.debug('sorting nodes ...')
  sort_idx = neighbor_argsort(nodes)
  logger.debug('done')
  nodes = nodes[sort_idx]
  normals = normals[sort_idx]
  reverse_sort_idx = np.argsort(sort_idx)
  groups = {}
  for k, v in groups.items():
      if len(v) > 0: groups[k] = reverse_sort_idx[v]
  #groups = {k: reverse_sort_idx[v] for k, v in groups.items()}

  logger.debug('checking the quality of the generated nodes ...')
  _check_spacing(nodes, rho)
  logger.debug('done')

  return nodes, groups, normals