ArcGIS Blog

Data Management

ArcGIS Pro

Template Attribute Rules - Generate Spatial Join

By Mihail Kaburis

In ArcGIS Pro 3.4 we have introduced three new Template Attribute Rules that allow you to generate attribute rules tailored to specific workflows. We took some of the most commonly used attribute rules and created a geoprocessing (GP) tool experience to guide you through their creation process. This enhancement not only speeds up the generation of these rules but also makes them more accessible for users who may not have prior knowledge of Arcade scripting.

This blog describes a workflow of creating and utilizing a Generate Spatial Join template attribute rule to determine the number of lots and average property values of a few parcels in New Haven, Connecticut.

Spatial joins facilitate analysis and retrieval of information from multiple feature classes based on their spatial relationships. This means that an explicit attribute indicating that “feature class A is inside of feature class B” is unnecessary. Instead, we utilize the inherent relationships between features to determine if they are spatially joined to one another. The spatial relationship between parcels and lots is quite intuitive: parcels contain lots.

Here’s what our data looks like (note: the light green polygons represent parcels and the yellow polygons represent lots):

Manually counting the number of lots within each parcel and determining their respective average property values can be a tedious and error-prone process. This task becomes even more daunting when it expands beyond the few lots in a city to encompass the entire county or even a whole state such as Connecticut. Additionally with frequent changes in urban landscapes, it is crucial for parcel data to be regularly updated for taxation and planning purposes. Imagine investing significant effort to obtain precise counts, only to find that you need to recount later. Not to worry though! As GIS professionals, we have a solution to streamline this process!

Here’s a brief overview video of the Generate Spatial Join Attribute Rule tool:

Utilizing the Generate Spatial Join Tool

Review Generate Spatial Join Data

Note: Attribute Rules require an ArcGIS Pro Standard or Advanced license level. To learn more please refer to the ArcGIS Pro license levels help documentation.

  1. Download the TemplatedARs_SpatialJoin.ppkx project package file and then click on the file to open the TemplatedARs_SpatialJoin project. This project contains the data to step through the workflow in this blog.
  2. The project opens with the New Haven Lots and Parcels map active. The map contains two polygon feature classes representing lots and parcels.

 

Let’s go ahead and determine the number of parcels and average property values within the example data.

The first thing we are going to want to do is right click on the Parcels layer in the Table of Contents pane and select Data Design > Attribute Rules to open the Attribute Rules Data Design View.

In the Attribute Rules tab of the ribbon click on the bottom half of the Calculation Rules button. In the Context Menu go to Templates > Generate Spatial Join.

The Generate Spatial Join Attribute Rule GP tool dialog opens. Accessing the tool via the Attribute Rules Data Design View automatically selects the Parcels feature class as the Input Table for which the attribute rule will be created on.

Tool Overview

Here are the parameters for the tool and how we’ll fill them out:

  1. The optional Expression parameter allows us to specify a SQL expression to restrict which features from the Input Table participate with the attribute rule. Since we want all of the Parcel features to participate in the spatial joins, we’ll leave this parameter with the default settings.
  2. The Join Classes parameter represents the attributes from the join features that will be joined to the attributes of the input features. Go ahead and select the Lots class. To keep things straightforward, we are using a single join class but we could have more!
  3. The Field Map parameter is next. It controls the mapping of fields from the Join Classes parameter to the Input Table parameter. It might look daunting, but don’t be alarmed! Think of the field mappings as the linkages between the attributes from the Lots feature class to those in our Parcels class.
    1. There are two objectives in our analysis:
      1. Count the number of lots within each parcel.
      2. Find the average property value of lots within each parcel.
    2. Note: The Generate Spatial Join Attribute Rule Tool will automatically create new fields added to the field maps in the Input Table feature class.
    3. In the Field Map parameter go ahead and select the Edit In the Fields list go and select all the fields and click the Remove button…bam we have a clean slate!
    4. Click on the dropdown arrow next Add Fields button and click Add Empty Field. Name the field NumberOfLots. Change the type to Long. For the Actions dropdown select Count. For the Source Fields choose OBJECTID

 

e. Now let’s create a Field Map for average property values within each parcel. Click on the dropdown arrow next to the Add Fields button again and click Add Empty Field. Name the field AvgPropertyValue. Change the Type to Double. Under the Actions dropdown select Mean. For Source Fields choose PropertyValue.

  1. The optional Search Options parameter allows us to define the spatial queries from the Input Table and Join Classes parameter values. Every Join Class gets its own set of Search Options parameters.
    1. The Join Class is the name of the class that the spatial query will be run on.
    2. The Input Geometry Type is the portion of the input geometry that will be used to query the join class. There are four geometry types:
      1. Geometry – the complete geometry of the input feature. This is default. For our example we will choose this geometry type.
      2. Start – The first vertex within a polyline feature. (Note: this option is only available on join classes that are polylines).
      3. End – The last vertex within a polyline feature. (Note: this option is only available on join classes that are polylines).
      4. Centroid – The geometric center of the input feature.
    1. The Spatial Operator is the spatial operation that will be used in the query. There are several spatial operators available:
      1. Intersects – Features in the join class will be matched if they intersect with an input feature. This is default.
      2. Crosses – Features in the join class will be matched if they cross with an input feature.
      3. Contains – Features in the join class will be matched if an input feature contains the joined class features. This is the opposite of the Within option.
        • The spatial relationship in our example is based on the notion that a parcel contains lots so we’ll choose it as the Spatial Operator for our example.
      1. Envelope_Intersects – Features in the join class will be matched if their bounding boxes (envelopes) intersect with the bounding box of an input feature.
      2. Overlaps – Features in the join class will be matched if they overlap with an input feature. The join class features are not completely contained by the input features.
      3. Touches – Features in the join class will be matched if they have a boundary that touches an input feature. When the input and join features are polylines or polygons, the boundary of the join feature can only touch the boundary of the input feature and no part of the join feature can cross the boundary of the input feature.
      4. Within – Features in the join class will be matched if an input feature is within them. This is the opposite of the Contains option.
    1. The Search Distance is the distance from the geometry that will be included in the query. To keep things simple, we will not specify a search distance (this is default). This means that there is no buffer between input features and join features for which they can be matched on.

Review and Modify the Generated Rule

Click on the OK button at the bottom of the dialog to generate the spatial join template attribute rule.

In the Attribute Rules Data Design View, you should now see a new attribute rule called Spatial Join. Let’s rename it to “Spatial Join – Appraise Parcels”. In the Details Pane add a description for the rule such as the following: “Determine the number of lots and the average property value within each parcel.”

If you scroll down to the script expression in the Details Pane you can see the Arcade Script that was generated. It looks like this:

 

Click here to view a Generate Spatial Join Template Attribute Rule Arcade Expression
/* 
field_mappings (Array): Source and target field mappings.
  source_field (Text): The source field to be populated.
  action (Text): The action to apply on the source fields. Count/First/Last/Join/Min/Max/Mean/Range/Sum/StDev/Mode.
  delimiter (Text): The delimiter to join multiple fields when the action is 'Join'.
  field_map (Array): The target classes and fields.
    class_name (Text): The unique name referencing the target class.
    target_fields (Array): The target field(s) to populate source_field.
target_classes (Array): Target classes and search options.
  class_name (Text): The unique name referencing the target class.
  where_clause (Text): The optional string to filter class_name.
  order_by_clause (Text): The optional string to apply ASC/DESC sorting on class_name.
  spatial_operator (Text): The optional string for spatial filtering. Intersects/Within.
  search_distance (Number): The optional buffer distance to apply to spatial_operator.
  search_units (Text): The optional units to apply to search_distance.
  input_geometry (Geometry): The optional shape to use for spatial querying.
*/
var rule_settings = {
  'where_clause': null,
  'field_mappings': [
    {
      'source_field': 'NumberOfLots',
      'action': 'Count',
      'delimiter': '',
      'field_map': [
        {
          'class_name': 'Lots',
          'target_fields': [
            'OBJECTID',
          ],
        },
      ],
    },
    {
      'source_field': 'AvgPropertyValue',
      'action': 'Mean',
      'delimiter': '',
      'field_map': [
        {
          'class_name': 'Lots',
          'target_fields': [
            'PropertyValue',
          ],
        },
      ],
    },
  ],
  'target_classes': [
    {
      'class_name': 'Lots',
      'where_clause': null,
      'order_by_clause': 'objectid ASC',
      'spatial_operator': 'Contains',
      'search_distance': null,
      'search_units': null,
      'input_geometry': Geometry($feature),
    },
  ],
};

function get_feature_set(key) {
  return Decode(
    key,
    'Lots', FeatureSetByName($datastore, 'main.Lots', ['OBJECTID', 'PropertyValue'], false),
    null
  );
}

function IsEmpty2(value) {
  var type = TypeOf(value);
  if (type == '') {
    return true; // null
  } else if (type == 'Boolean') {
    return !value;
  } else if (type == 'String') {
    return IsEmpty(value);
  } else if (
    (type == 'Array') ||
    (type == 'Dictionary') ||
    (type == 'FeatureSet')
  ) {
    for (var x in value) {
      return false;
    }
    return true;
  } else if (type == 'Number') {
    return IsNan(value);
  } else if (type == 'Point') {
    return IsNan(value.x);
  } else if (type == 'Multipoint') {
    return Count(value.points) == 0;
  } else if (type == 'Polyline') {
    return Count(value.paths) == 0;
  } else if (type == 'Polygon') {
    return Count(value.rings) == 0;
  } else if (type == 'Extent') {
    return IsNan(value.xmin);
  } else if (
    (type == 'Feature') ||
    (type == 'DateOnly') ||
    (type == 'Time') ||
    (type == 'Date') ||
    (type == 'FeatureSetCollection') ||
    (type == 'Portal') ||
    (type == 'Function')
  ) {
    return false
  }
  return null;
}

function features_to_featureset(features) {
  // Converts features array to feature set.
  if (TypeOf(features) == 'FeatureSet') {
    return features;
  }
  var rows = [];
  var feat, feat_dict;
  for (var i in features) {
    feat = features[i];
    feat_dict = {
      '__oid__': i + 1, // Incrementing OID field.
    };
    for (var j in feat) {
      feat_dict[j] = feat[j];
    }
    Push(rows, {
      'attributes': feat_dict,
    });
  }
  if (IsEmpty(feat)) {
    return;
  }

  // Add OID field to schema.
  var feat_schema = Array(Schema(feat).fields);
  Push(feat_schema, {
    'name': '__oid__',
    'type': 'esriFieldTypeInteger',
  });
  return FeatureSet({
    'fields': feat_schema,
    'features': rows,
  });

}

function count_features(features, where_clause) {
  // count features that match where_clause
  if (IsEmpty2(features)) {
    return 0;
  }
  if (IsEmpty(where_clause)) {
    return Count(features);
  }
  var fs = features_to_featureset(features);
  if (IsEmpty(fs)) {
    return 0;
  }
  return Count(Filter(fs, where_clause));
}

function not_null(x) {
  return x != null;
}

function get_unit_code(unit) {
  // Converts unit string to unit code
  if (IsEmpty(unit)) {
    return
  }

  var u = Lower(Replace(Replace(Replace(unit, ' ', ''), '-', ''), '_', ''));
  // US / INT suffix differentiates the unit code. If no suffix, then it defaults to US Survey.
  var international = false;
  if (Right(u, 2) == 'us') {
    u = Left(u, Count(u) - 2);
  } else if (Right(u, 3) == 'int' && u != 'point') {
    u = Left(u, Count(u) - 3);
    international = true;
  }
  if (Right(u, 1) == 's' && u != 'inches') { // plural
    u = Left(u, Count(u) - 1);
  }
  return When(
    // Metric
    u == 'km' || u == 'kilometer', 9036,
    u == 'm' || u == 'meter', 9001,
    u == 'dm' || u == 'decimeter', 109005,
    u == 'cm' || u == 'centimeter', 1033,
    u == 'mm' || u == 'millimeter', 1025,

    // US Survey / International
    u == 'nmi' || u == 'nauticalmile', IIf(international, 9030, 109012),
    u == 'mi' || u == 'mile', IIf(international, 9093, 9035),
    u == 'yd' || u == 'yard', IIf(international, 9096, 109002),
    u == 'ft' || u == 'foot' || u == 'feet', IIf(international, 9002, 9003),
    u == 'in' || u == 'inch' || u == 'inches', IIf(international, 109008, 109009),

    // Misc
    u == 'dd' || u == 'deg' || u == 'degree' || u == 'decimaldegree', 9102,
    u == 'pt' || u == 'point', 109016,

    // Default
    null,
  )

}

function transpose_feature_set(feature_set) {
  // Converts a feature set (row-store) to a dictionary of rows (column-store)

  // Get the field names from the FeatureSet and create an array of arrays of the same length.
  var columns = [];
  var fields = [];
  var field_info = Schema(feature_set).fields;
  for (var i in field_info) {
    Push(columns, []);
    Push(fields, field_info[i].name);
  }

  for (var row in feature_set) {
    for (var j in fields) {
      Push(columns[j], row[fields[j]]);
    }
  }

  var lookup = {};
  for (var k in fields) {
    lookup[fields[k]] = columns[k];
  }
  return lookup;
}

function apply_merge_rule(rule, delimiter, data) {
  // Applies rule to data array

  // Nulls are always excluded from calculations.
  data = Filter(data, not_null);
  if (IsEmpty2(data)) {
    return null;
  }

  // Assume array is a homogenous data type.
  var first_val = data[0];
  var data_type = TypeOf(data[0]);

  var DateFunc = Decode(data_type, 'Date', Date, 'DateOnly', DateOnly, 'Time', Time, null);
  var StatFunc = Decode(rule, 'Min', Min, 'Max', Max, 'Mean', Average, null);

  if (rule == 'Count') {
    return Count(data);
  } else if (rule == 'First') {
    return data[0];
  } else if (rule == 'Last') {
    return data[-1];
  } else if (rule == 'Join') {
    return Concatenate(data, delimiter);
  } else if (StatFunc != null) {
    if (data_type == 'Number') {
      return StatFunc(data);
    } else if (DateFunc != null) {
      // Convert dates to number for statistic and then back to date.
      var date_val = DateFunc(StatFunc(Map(data, Number)));
      if (data_type == 'Date') {
        // Remove timezone if input is naïve, otherwise convert to UTC.
        return IIf(
          TimeZone(first_val) == 'Unknown',
          ChangeTimeZone(ToUTC(date_val), 'Unknown'),
          ToUTC(date_val)
        );
      } else {
        return date_val;
      }
    } else {
      return null;
    }
  } else if (rule == 'Range') {
    if (data_type == 'Number') {
      return Max(data) - Min(data);
    } else if (data_type == 'Time') {
      var total_ms = Map(data, Number);
      return Time(Max(total_ms) - Min(total_ms));
    } else {
      return null;
    }
  } else if (rule == 'Sum') {
    if (data_type == 'Number') {
      return Sum(data);
    } else {
      return null;
    }
  }

  if (data_type != 'Number') {
    return null;
  }

  var val;
  if (rule == 'StDev') {
    val = StDev(data);
  } else if (rule == 'Median') {
    data = Sort(data);
    var n = Count(data);
    val = IIf(n % 2 == 0, Average(data[n / 2 - 1], data[n / 2]), data[(n - 1) / 2]);
  } else if (rule == 'Mode') {
    data = Sort(data);
    var counter = 0;
    var prev = data[0];
    var hi_count = 1;
    var hi_val = prev;
    for (var i in data) {
      if (data[i] == prev) {
        if (++counter > hi_count) {
          hi_count = counter;
          hi_val = data[i];
        }
      } else {
        counter = 1;
      }
      prev = data[i];
    }
    val = hi_val;
  } else {
    return null;
  }

  return IIf(IsNan(val), null, val);

}

function apply_sql_spatial_filter(feature_set, options) {
  // Applies optional spatial/attribute filters and OrderBy clause to feature_set.
  if (feature_set == null){
    return;
  }
  var spatialFilter = Decode(
    Lower(DefaultValue(options, 'spatial_operator', '')),
    'intersects', Intersects,
    'contains', Contains,
    'crosses', Crosses,
    'envelopeintersects', EnvelopeIntersects,
    'overlaps', Overlaps,
    'touches', Touches,
    'within', Within,
    null
  );

  var geo = DefaultValue(options, 'input_geometry', null);
  if (!IsEmpty(spatialFilter) && !IsEmpty2(geo)) {
    if (!IsEmpty(DefaultValue(options, 'search_distance', null))) {
      geo = Buffer(geo, options.search_distance, get_unit_code(options.search_units));
    }
    feature_set = spatialFilter(feature_set, geo);
  }
  if (!IsEmpty(DefaultValue(options, 'where_clause', null))) {
    feature_set = Filter(feature_set, options.where_clause);
  }
  if (!IsEmpty(DefaultValue(options, 'order_by_clause', null))) {
    feature_set = OrderBy(feature_set, options.order_by_clause);
  }
  return feature_set;
}

if (count_features([$feature], DefaultValue(rule_settings, 'where_clause', null)) != 1) {
  return;
}

function calculate() {
  var target_rows = {};
  for (var target_idx in rule_settings.target_classes) {
    var target = rule_settings.target_classes[target_idx];
    target_rows[target.class_name] = transpose_feature_set(
      apply_sql_spatial_filter(
        get_feature_set(target.class_name),
        target,
      )
    )
  }

  var result = {};
  for (var fms_idx in rule_settings.field_mappings) {
    var fms = rule_settings.field_mappings[fms_idx];

    // Each field can be populated by any number of join classes and fields. Merge into a single array.
    var rows = [];
    for (var fm_idx in fms.field_map) {
      var fm = fms.field_map[fm_idx];
      for (var i in fm.target_fields) {
        rows = Splice(rows, DefaultValue(target_rows, [fm.class_name, fm.target_fields[i]], []));
      }
    }
    if (!IsEmpty2(rows)) {
      result[fms.source_field] = apply_merge_rule(fms.action, fms.delimiter, rows);
    }
  }
  return result;
}

return {
  'result': {
    'attributes': calculate()
  }
}

 

In the Ribbon click on the Save button. Close the Attribute Rules Data Design View and head back to the map with the parcel and lots layers.

In the Contents pane right click on the Parcels layer and open the Attributes Table. Notice the two new fields we created in the tool – NumberOfLots and AvgPropertyValue. Update the ParcelType for ParcelID=1 from “Unknown” to “Residential”

The number of lots and average property value are calculated for ParcelID=1.

FAQs

Do I need a specific ArcGIS Pro license type to use template attribute rules?

You will need either an ArcGIS Pro Standard or Advanced licensing type to access template attribute rules.

What are the differences between Attribute Rule Templates and Ready to Use Rules?

Template attribute rules can be used to generate calculation attribute rules using geoprocessing tools After generating a template attribute rule a user can modify the Arcade script associated with it and any other attribute rule properties via the Details pane.

Ready to Use Rules require a Data Reviewer license in addition to an ArcGIS Standard or Advanced licensing type. Ready to Use Rules can be used to generate Constraint and Validation rules via a user interface in the Attribute Rule Data Design View details pane. These rules are used to detect features that do not comply with established data quality requirements defined by your organization. The underlying arcade expressions for review rules is hidden. To learn more about reviewer rules please visit the Manage reviewer rules in a geodatabase help link.

Where can I learn more about the Generate Spatial Join template attribute rule?

To learn more about the Generate Spatial Join template attribute rule, review following online help documents:

Share this article