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