santa_cruz_public_works.py
1    # ----------------------------------------------------------------------------------------------------------------------
2    # santa_cruz_public_works.py
3    # Christopher Prendergast
4    # 2024/05/12
5    # ----------------------------------------------------------------------------------------------------------------------
6    #
7    import arcpy
8    from pathlib import Path
9    
10   global g_in_gdb
11   global g_out_gdb
12   
13   
14   def full_path(root_dir: str, basename: str) -> str:
15       """ 
16       Convert basename to a full path given the root directory or geo-database path. 
17    
18       :param str root_dir:    The root directory or file geo-database path. 
19       :param str basename:    The basename of a geo-database or feature class. 
20       :return str:            The root and basename joined as a path. 
21       """
22       return str(Path(root_dir, basename))
23   
24   
25   def setup_env(proj_dir_str: str, in_gdb_str: str, out_gdb_str: str) -> None:
26       """ 
27       Set up the geo-database environment. Assign values to global 
28       variables g_in_gdb and g_out_gdb. 
29    
30       :param str proj_dir_str:    The project directory. 
31       :param str in_gdb_str:      The basename of the input geo-database within the project directory. 
32       :param str out_gdb_str:     The basename of the output geo-database within the project directory. 
33       :return NoneType:           None 
34       """
35       #
36       # Allow overwriting outputs.
37       # Note: overwriting doesn't work if the layer is open in ArcGIS Pro due
38       # to lock. Closing ArgGIS Pro releases the lock and the outputs can be
39       # overwritten. See:
40       # https://community.esri.com/t5/python-questions/arcpy-env-overwriteoutput-true-fails/m-p/411113#M32410
41       #
42       arcpy.env.overwriteOutput = True
43       #
44       # Check the project directory exists.
45       #
46       proj_dir = Path(proj_dir_str)
47       assert proj_dir.is_dir(), \
48           f"Can't find the project directory {proj_dir}"
49       print("...project directory:", proj_dir)
50       #
51       # Assign global variables for the input and output geo-databases.
52       #
53       global g_in_gdb
54       g_in_gdb = full_path(proj_dir_str, in_gdb_str)
55       global g_out_gdb
56       g_out_gdb = full_path(proj_dir_str, out_gdb_str)
57       #
58       # Check the input and output geo-databases exist.
59       #
60       assert arcpy.Exists(g_in_gdb), \
61           f"Can't find input geo-database: {g_in_gdb}"
62       assert arcpy.Exists(g_out_gdb), \
63           f"Can't find output geo-database: {g_out_gdb}"
64       print("...input geo-database:", g_in_gdb)
65       print("...output geo_database:", g_out_gdb)
66   
67   
68   def buffer(in_fc: str, buffer_dist: str, out_fc: str) -> str:
69       """ 
70        Run the Pairwise Buffer geo-processing tool. 
71    
72       :param str in_fc:       The input feature class to buffer. 
73       :param str buffer_dist: The buffer distance in the string format expected by the tool e.g. "100 feet". 
74       :param str out_fc:      The feature class to output containing the buffered features. 
75       :return str:            The output feature class. 
76       """
77       print("...buffer, input feature class:", in_fc)
78       print("...buffer, output feature class:", out_fc)
79       print("...buffer, distance:", buffer_dist)
80       assert arcpy.Exists(in_fc), \
81           f"Can't find input feature class: {in_fc}"
82       assert arcpy.Exists(g_out_gdb), \
83           f"Can't find output geo-database: {g_out_gdb}"
84       #
85       # Create the buffer feature class.
86       #
87       arcpy.analysis.PairwiseBuffer(
88           in_features=in_fc,
89           out_feature_class=out_fc,
90           buffer_distance_or_field=buffer_dist
91       )
92       return out_fc
93   
94   
95   def join(target_fc: str, join_fc: str, out_fc: str) -> str:
96       """ 
97       Run the Spatial Join geo-processing tool. 
98    
99       :param str target_fc:   The target feature class. 
100      :param str join_fc:     The join feature class. 
101      :param str out_fc:      The feature class to output containing the joined features. 
102      :return str:            The output feature class. 
103      """
104      print("...join, target feature class:", target_fc)
105      print("...join, join feature class:", join_fc)
106      print("...join, output feature class:", out_fc)
107      assert arcpy.Exists(target_fc), \
108          f"Can't find target feature class: {target_fc}"
109      assert arcpy.Exists(join_fc), \
110          f"Can't find join feature class: {join_fc}"
111      assert arcpy.Exists(g_out_gdb), \
112          f"Can't find output geo-database: {g_out_gdb}"
113      #
114      # Configure the field mapping.
115      #
116      mapping = map_fields(target_fc, join_fc)
117      #
118      # Spatially join the target feature class to the join feature class.
119      # Counterintuitively, a 1:1 join aggregates rows when there are 
120      # multiple spatial matches!
121      #
122      arcpy.analysis.SpatialJoin(
123          target_features=target_fc,
124          join_features=join_fc,
125          out_feature_class=out_fc,
126          field_mapping=mapping
127      )
128      return out_fc
129  
130  
131  def map_fields(target_fc: str, join_fc: str) -> arcpy.FieldMappings:
132      """ 
133      Create a field mappings object which can be passed as parameter  
134      passed to the Spatial Join geo-processing tool. Since a single 
135      feature can be within 100 feet of more than one road, mappings 
136      with both "First" and "Join" merge rules are created. This will 
137      add two new fields in the output, namely, "first_road_name" and 
138      "concat_road_names" respectively. 
139   
140      :param target_fc:               The target feature class. 
141      :param join_fc:                 The join feature class. 
142      :return arcpy.FieldMappings:    The field mappings object. 
143      """
144      print("...map fields, target feature class:", target_fc)
145      print("...map fields, join feature class:", join_fc)
146      assert arcpy.Exists(target_fc), \
147          f"Can't find target feature class: {target_fc}"
148      assert arcpy.Exists(join_fc), \
149          f"Can't find join feature class: {join_fc}"
150  
151      def add_output_field(fm: arcpy.FieldMap, name: str) -> None:
152          """ 
153          Helper function to configure an output field. 
154   
155          :param arcpy.FieldMap fm:   The field map to add the output field to. 
156          :param str name:            The name to give to the new output field. 
157          :return NoneType:           None 
158          """
159          fld = fm.outputField
160          fld.type = "TEXT"
161          fld.name = name
162          fld.aliasName = name
163          fm.outputField = fld
164  
165      #
166      # Create a field mappings object and add the fields from the
167      # target feature class.
168      #
169      fms = arcpy.FieldMappings()
170      fms.addTable(target_fc)
171      #
172      # Remove the RHG_NAME field from the target field mapping because 
173      # we are adding the road name from the join feature class below.
174      #
175      fms.removeFieldMap(fms.findFieldMapIndex("RHG_NAME"))
176      #
177      # Add "first_road_name" field which will contain the first matching
178      # road name.
179      #
180      # Create a new field map object and add the RHG_NAME field from the
181      # join feature class.
182      #
183      first_fm = arcpy.FieldMap()
184      first_fm.addInputField(join_fc, "RHG_NAME")
185      #
186      # Specify the merge rule -- take the first matching value when 
187      # there are multiple matches.
188      #
189      first_fm.mergeRule = "First"
190      #
191      # Configure the output field.
192      #
193      add_output_field(first_fm, "first_road_name")
194      #
195      # Add this field mapping to the field mappings.
196      #
197      fms.addFieldMap(first_fm)
198      #
199      # Add "concat_road_names" field which will contain all the matching
200      # road name concatenated and separated with a comma.
201      #
202      # Create a new field map object and add the RHG_NAME field from the
203      # join feature class.
204      #
205      concat_fm = arcpy.FieldMap()
206      concat_fm.addInputField(join_fc, "RHG_NAME")
207      #
208      # Specify the merge rule -- Concatenate matching value when there 
209      # are multiple matches.
210      #
211      concat_fm.mergeRule = "Join"
212      concat_fm.joinDelimiter = ","
213      #
214      # Configure the output field.
215      #
216      add_output_field(concat_fm, "concat_road_names")
217      #
218      # Add this field mapping to the field mappings.
219      #
220      fms.addFieldMap(concat_fm)
221      return fms
222  
223  
224  def copy_field(from_fc: str, from_key_name: str, from_field_name: str,
225                 to_fc: str, to_key_name: str, to_field_name: str) -> None:
226      """ 
227      Copy the value of a field from one feature class to another where the specified keys match. 
228   
229      :param str from_fc:             The source feature class to copy from. 
230      :param str from_key_name:       The name of the key field in the source feature class. 
231      :param str from_field_name:     The name of the field containing the value to copy in the source feature class. 
232      :param str to_fc:               The target feature class to copy to. 
233      :param str to_key_name:         The name of the key field in the target feature class. 
234      :param str to_field_name:       The name of the field that will contain the new value in the target feature class. 
235      :return NoneType:               None 
236      """
237      print("...copy field, from feature class:", from_fc)
238      print("...copy field, to feature class:", to_fc)
239      assert arcpy.Exists(from_fc), \
240          f"Can't find from feature class: {from_fc}"
241      assert arcpy.Exists(to_fc), \
242          f"Can't find to feature class: {to_fc}"
243      #
244      # Check the fields exist.
245      #
246      for field in [from_key_name, from_field_name]:
247          assert arcpy.ListFields(from_fc, field), \
248              f"The field {field} does not exist in {from_fc}"
249      for field in [to_key_name, to_field_name]:
250          assert arcpy.ListFields(to_fc, field), \
251              f"The field {field} does not exist in {to_fc}"
252      #
253      # Create a dictionary to hold keys and field values from the
254      # source feature class.
255      #
256      from_values_dict = {}
257      #
258      # Iterate through the rows in the source feature class and add
259      # field values to the dictionary.
260      #
261      with arcpy.da.SearchCursor(from_fc, [from_key_name, from_field_name]) as search_cur:
262          for k, v in search_cur:
263              from_values_dict[k] = v
264      #
265      # Iterate through the target feature class and update the field
266      # values where they keys match.
267      #
268      with arcpy.da.UpdateCursor(to_fc, [to_key_name, to_field_name]) as upd_cur:
269          for to_row in upd_cur:
270              this_key = to_row[0]
271              #
272              # Update the field using the value from the dictionary.
273              #
274              if this_key in from_values_dict.keys():
275                  to_row[1] = from_values_dict[this_key]
276              upd_cur.updateRow(to_row)
277  
278  
279  def run_tools() -> None:
280      """ 
281      Run the geo-processing tools to buffer and join the various feature 
282      classes. 
283   
284      :return NoneType: None 
285      """
286      #
287      # Specify the input feature classes.
288      #
289      roads = full_path(g_in_gdb, "PublicWorks_Roads")
290      signs = full_path(g_in_gdb, "StreetSigns")
291      poles = full_path(g_in_gdb, "StreetSignPoles")
292      lights = full_path(g_in_gdb, "StreetLights")
293      #
294      # Specify the output feature classes.
295      #
296      roads_buff = full_path(g_out_gdb, "roads_buffer_100ft")
297      signs_join = full_path(g_out_gdb, "signs_join")
298      poles_join = full_path(g_out_gdb, "poles_join")
299      lights_join = full_path(g_out_gdb, "lights_join")
300      #
301      # Run the geo-processing tools.
302      #
303      buffer(roads, "100 feet", roads_buff)
304      join(signs, roads_buff, signs_join)
305      join(poles, roads_buff, poles_join)
306      join(lights, roads_buff, lights_join)
307      #
308      # Update the original feature classes with the road names.
309      # Note: There can be multiple roads within 100 ft of a feature.
310      # Choose either:
311      #   first_road_name to get the first matching name, or
312      #   concat_road_names to concatenate all matching names.
313      #
314      # from_field = "first_road_name"
315      from_field = "concat_road_names"
316      #
317      # Note: must use the field names not their aliases for the key fields!
318      #
319      copy_field(signs_join, "SVG_NUM",
320                 from_field, signs, "SVG_NUM", "RHG_NAME")
321      copy_field(poles_join, "PLG_NUM",
322                 from_field, poles, "PLG_NUM", "RHG_NAME")
323      copy_field(lights_join, "POLE_NUM",
324                 from_field, lights, "POLE_NUM", "RHG_NAME")
325  
326  
327  if __name__ == '__main__':
328      #
329      # Define locations of geodatabases.
330      #
331      my_proj_dir = r"C:\ArcGIS_local_projects\SantaCruzPublicWorks"
332      my_in_gdb = "PublicWorks_RoadNames.gdb"
333      my_out_gdb = "PythonOutput.gdb"
334      setup_env(my_proj_dir, my_in_gdb, my_out_gdb)
335      #
336      # Run the main process.
337      #
338      run_tools()
339  
340  # ----------------------------------------------------------------------------------------------------------------------
341  # Sample Output
342  # ----------------------------------------------------------------------------------------------------------------------
343  # "C:\Program Files\ArcGIS\Pro\bin\Python\envs\arcgispro-py3\python.exe" C:\ArcGIS_local_projects\SantaCruzPublicWorks\santa_cruz_public_works.py
344  # ...project directory: C:\ArcGIS_local_projects\SantaCruzPublicWorks
345  # ...input geo-database: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb
346  # ...output geo_database: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb
347  # ...buffer, input feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\PublicWorks_Roads
348  # ...buffer, output feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
349  # ...buffer, distance: 100 feet
350  # ...join, target feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetSigns
351  # ...join, join feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
352  # ...join, output feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\signs_join
353  # ...map fields, target feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetSigns
354  # ...map fields, join feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
355  # ...join, target feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetSignPoles
356  # ...join, join feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
357  # ...join, output feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\poles_join
358  # ...map fields, target feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetSignPoles
359  # ...map fields, join feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
360  # ...join, target feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetLights
361  # ...join, join feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
362  # ...join, output feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\lights_join
363  # ...map fields, target feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetLights
364  # ...map fields, join feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\roads_buffer_100ft
365  # ...copy field, from feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\signs_join
366  # ...copy field, to feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetSigns
367  # ...copy field, from feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\poles_join
368  # ...copy field, to feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetSignPoles
369  # ...copy field, from feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PythonOutput.gdb\lights_join
370  # ...copy field, to feature class: C:\ArcGIS_local_projects\SantaCruzPublicWorks\PublicWorks_RoadNames.gdb\StreetLights
371  #
372  # Process finished with exit code 0
373  # ----------------------------------------------------------------------------------------------------------------------
374