├── .gitignore ├── CONTRIBUTING.md ├── LargeNetworkAnalysisTools.ParallelCalculateLocations.pyt.xml ├── LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs.pyt.xml ├── LargeNetworkAnalysisTools.SolveLargeODCostMatrix.pyt.xml ├── LargeNetworkAnalysisTools.pyt ├── README.md ├── conference-slides ├── DevSummit2020_SolvingLargeTransportationAnalysisProblems.pdf ├── DevSummit2021_SolvingLargeTransportationAnalysisProblems.pdf └── DevSummit2022_SolvingLargeTransportationAnalysisProblems.pdf ├── helpers.py ├── images ├── CalculateLocations_SQL_1.png ├── CalculateLocations_SQL_2.png ├── ParallelCalculateLocations_Dialog.png ├── ParallelCalculateLocations_SQL.png ├── SolveLargeAnalysisWithKnownODPairs_ManyToMany_Dialog.png ├── SolveLargeAnalysisWithKnownODPairs_OneToOne_Dialog.png └── SolveLargeODCostMatrix_Dialog.png ├── license.txt ├── od_config.py ├── parallel_calculate_locations.py ├── parallel_odcm.py ├── parallel_route_pairs.py ├── rt_config.py ├── solve_large_odcm.py ├── solve_large_route_pair_analysis.py └── unittests ├── __init__.py ├── input_data_helper.py ├── portal_credentials.py ├── test_ParallelCalculateLocations_tool.py ├── test_SolveLargeAnalysisWithKnownPairs_tool.py ├── test_SolveLargeODCostMatrix_tool.py ├── test_helpers.py ├── test_parallel_calculate_locations.py ├── test_parallel_odcm.py ├── test_parallel_route_pairs.py ├── test_solve_large_odcm.py ├── test_solve_large_route_pair_analysis.py └── unittests_README.txt /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | odcm_outputs.txt 3 | 4 | *.pyc 5 | 6 | LargeNetworkAnalysisTools.pyt.xml 7 | 8 | /unittests/TestInput 9 | /unittests/TestOutput 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Esri welcomes contributions from anyone and everyone. Please see our [guidelines for contributing](https://github.com/esri/contributing). -------------------------------------------------------------------------------- /LargeNetworkAnalysisTools.ParallelCalculateLocations.pyt.xml: -------------------------------------------------------------------------------- 1 | 2 | 20230330153136001.0TRUE20230405160344001500000005000ItemDescriptionc:\program files\arcgis\pro\Resources\Help\gp<DIV STYLE="text-align:Left;"><DIV><P><SPAN>The point feature class or layer whose network locations you want to calculate.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The catalog path to the output feature class.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Network dataset or network dataset layer to use when calculating network locations.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Defines the number of features that will be in each chunk in the parallel processing.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Defines the maximum number of parallel processes to run at once. Do not exceed the number of logical processors of your machine.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Network travel mode to use when calculating network locations.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The maximum search distance that will be used when locating the input features on the network. Features that are outside the search tolerance will be left unlocated.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The network dataset source feature classes on which input features are allowed to locate.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>An optional query for each network dataset source feature class filtering the source features that can be located on.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Calculate network locations for a large dataset by chunking it and solving in parallel.</SPAN></P></DIV></DIV>Parallel Calculate Locationsnetwork locationscalculate locationsnetwork analystparallelArcToolbox Tool20230405 3 | -------------------------------------------------------------------------------- /LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs.pyt.xml: -------------------------------------------------------------------------------- 1 | 2 | 20220401095418001.0TRUE2023010694021001500000005000ItemDescriptionc:\program files\arcgis\pro\Resources\Help\gp<DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The feature class or layer containing the origins. For the one-to-one Origin-Destination Assignment Type, the origins dataset must have a field populated with the ID of the destination the origin is assigned to.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>A field in origins representing the origin's unique ID. For the many-to-many Origin-Destination Assignment Type, the values in the Origin-Destination Pair Table's origin ID field must correspond to these unique origin IDs.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The feature class or layer containing the destinations.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><P><SPAN>A field in destinations representing the destination's unique ID. For the one-to-one Origin-Destination Assignment Type, the values in the origins table's Assigned Destination Field should correspond to these unique destination IDs. For the many-to-many Origin-Destination Assignment Type, the values in the Origin-Destination Pair Table's destination ID field must correspond to these unique destination IDs.</SPAN></P></DIV><DIV STYLE="text-align:Left;"><P><SPAN>A text string indicating which type of preassigned origin-destination pairs to use for the analysis. The options are:</SPAN></P><UL><LI><P><SPAN>A field in Origins defines the assigned Destination (one-to-one)</SPAN></P></LI><LI><P><SPAN>A separate table defines the origin-destination pairs (many-to-many)</SPAN></P></LI></UL></DIV><DIV STYLE="text-align:Left;"><P><SPAN>A field in Origins indicating the ID of the destination each origin is assigned to. Any origin with a null value or a value that does not match a valid destination ID will be ignored in the analysis. This parameter is only applicable for the one-to-one Origin-Destination Assignment Type.</SPAN></P></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>A table or CSV file defining origin-destination pairs. The table must be populated with a column of origin IDs matching values in the Origin Unique ID Field of the Origins table and a column of destination IDs matching values in the Destination Unique ID Field of the Destinations table. This parameter is only applicable for the many-to-many Origin-Destination Assignment Type.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The field name in the Origin-Destination Pair Table defining the origin IDs. This parameter is only applicable for the many-to-many Origin-Destination Assignment Type.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The field name in the Origin-Destination Pair Table defining the destination IDs. This parameter is only applicable for the many-to-many Origin-Destination Assignment Type.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Network dataset, network dataset layer, or portal URL to use when calculating the Route analysis.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Network travel mode to use when calculating the Route analysis.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The time units the output travel time will be reported in.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The distance units the output travel distance will be reported in.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Defines the chunk size for parallel Route calculations, the number of origin-destination routes to calculate simultaneously. For example, if you want to process a maximum of 1000 origins and 1000 destinations in a single chunk, for a total of 1000 paired routes, set this parameter to 1000.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Defines the maximum number of parallel processes to run at once. Do not exceed the number of logical processors of your machine.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Path to the output feature class that will contain the calculated routes between origins and their assigned destinations.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The start time of day for the analysis. No value indicates a time neutral analysis.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Point, line, or polygon barriers to use in the OD Cost Matrix analysis. This parameter is optional.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><P><SPAN>When you solve a network analysis, the input points must "locate" on the network used for the analysis. If origins and destinations are used more than once, it is more efficient to calculate the network location fields up front and re-use them. Set this parameter to True to pre-calculate the network location fields. This is recommended unless:</SPAN></P><UL><LI><P><SPAN>You are using a portal URL as the network data source. In this case, pre-calculating network locations is not possible, and the parameter is hidden.</SPAN></P></LI><LI><P><SPAN>You have already pre-calculated the network location fields using the network dataset and travel mode you are using for this analysis. In this case, you can save time by not precalculating them again.</SPAN></P></LI><LI><P><SPAN>Each destination has only one assigned origin. In this case, there is no efficiency gain in calculating the location fields in advance.</SPAN></P></LI></UL></DIV><DIV STYLE="text-align:Left;"><P><SPAN>A Boolean indicating whether to sort origins by their assigned destination prior to commencing the parallel solve. Using sorted data will improve the efficiency of the solve slightly. If your input data is already sorted, or if no origins are assigned to the same destinations, then sorting is not useful, and you should set this parameter to false. This parameter is only applicable for the one-to-one Origin-Destination Assignment Type.</SPAN></P></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>A Boolean indicating whether to reverse the direction of travel and calculate the route from the destination to the origin. The default is false. This parameter is only applicable for the one-to-one Origin-Destination Assignment Type.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Calculate the travel time and distance and generate routes between known origin-destination pairs by chunking up the problem and solving in parallel.</SPAN></P></DIV></DIV></DIV>Solve Large Analysis With Known OD PairsRoute pairsparallel processingOD Cost MatrixNetwork AnalystArcToolbox Tool20230106 3 | -------------------------------------------------------------------------------- /LargeNetworkAnalysisTools.SolveLargeODCostMatrix.pyt.xml: -------------------------------------------------------------------------------- 1 | 2 | 20210326134543001.0TRUE2023052585647001500000005000ItemDescriptionc:\program files\arcgis\pro\Resources\Help\gp<DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The feature class or layer containing the origins.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>The feature class or layer containing the destinations.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Network dataset, network dataset layer, or portal URL to use when calculating the OD Cost Matrix.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Network travel mode to use when calculating the OD Cost Matrix</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The time units the output Total_Time field will be reported in.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The distance units the output Total_Distance field will be reported in.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Defines the chunk size for parallel OD Cost Matrix calculations. For example, if you want to process a maximum of 1000 origins and 1000 destinations in a single chunk, set this parameter to 1000.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Defines the maximum number of parallel processes to run at once. Do not exceed the number of logical processors of your machine.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Path to the output feature class that will contain the updated origins, which may be spatially sorted and have added fields. The OriginOID field in the Output OD Lines Feature Class refers to the ObjectID of the Output Updated Origins and not the original input origins.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Path to the output feature class that will contain the updated destinations, which may be spatially sorted and have added fields. The DestinationOID field in the Output OD Lines Feature Class refers to the ObjectID of the Output Updated Destinations and not the original input destinations.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The desired output format for the OD Cost Matrix Lines. The available choices are:</SPAN></P><UL><LI><P><SPAN>"Feature class" - A single, combined feature class. This option is the slowest to create and will likely fail for extremely large problems.</SPAN></P></LI><LI><P><SPAN>"CSV files" - A set of .csv files. Each file represents the OD Cost Matrix Lines output, without shape geometry, for a chunk of origins and a chunk of destinations, using the naming scheme `ODLines_O_#_#_D_#_#.csv`, where the `#` signs represent the ObjectID ranges of the origins and destinations in the chunk. If you have set a value for the Number of Destinations to Find for Each Origin parameter, you may find some output files using the naming scheme `ODLines_O_#_#.csv` because results from all destinations have been combined into one file.</SPAN></P></LI><LI><P><SPAN>"Apache Arrow files" - A set of Apache Arrow files. Each file represents the OD Cost Matrix Lines output, without shape geometry, for a chunk of origins and a chunk of destinations, using the naming scheme `ODLines_O_#_#_D_#_#.arrow`, where the `#` signs represent the ObjectID ranges of the origins and destinations in the chunk. If you have set a value for the Number of Destinations to Find for Each Origin parameter, you may find some output files using the naming scheme `ODLines_O_#_#.arrow` because results from all destinations have been combined into one file. This option is not available in versions of ArcGIS Pro prior to 2.9 and is not available if the network data source is a service.</SPAN></P></LI></UL></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Path to the output feature class that will contain the OD Cost Matrix Lines output computed by the tool. The schema of this feature class is described in the </SPAN><A href="https://pro.arcgis.com:443/en/pro-app/latest/arcpy/network-analyst/origindestinationcostmatrix-output-data-types.htm" target="ESRI_SECTION1_9FF9489173C741DD95472F21B5AD8374" STYLE="text-decoration:underline;"><SPAN>arcpy documentation</SPAN></A><SPAN>.</SPAN></P></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Path to a folder, which will be created by the tool, that will contain the CSV or Arrow files representing the OD Cost Matrix Lines results if the Output OD Cost Matrix Format parameter value is "CSV files" or "Apache Arrow files". The schema of the files is described in the </SPAN><A href="https://pro.arcgis.com:443/en/pro-app/latest/arcpy/network-analyst/origindestinationcostmatrix-output-data-types.htm" target="ESRI_SECTION1_9FF9489173C741DD95472F21B5AD8374" STYLE="text-decoration:underline;"><SPAN>arcpy documentation</SPAN></A><SPAN>.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Impedance cutoff limiting the search distance for each origin. For example, you could set up the problem to find only destinations within a 15 minute drive time of the origins. This parameter is optional. Leaving it blank uses no cutoff.</SPAN></P><UL><LI><P><SPAN> If your travel mode has time-based impedance units, Cutoff represents a time and is interpreted in the units specified in the Time Units parameter.</SPAN></P></LI><LI><P><SPAN> If your travel mode has distance-based impedance units, Cutoff represents a distance and is interpreted in the units specified in the Distance Units parameter.</SPAN></P></LI><LI><P><SPAN> If your travel mode has other units (not time- or distance-based), Cutoff should be specified in the units of your travel mode's impedance attribute.</SPAN></P></LI></UL></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The number of destinations to find for each origin. For example, setting this to 3 will result in the output including the travel time and distance from each origin to its three closest destinations. This parameter is optional. Leaving it blank results in finding the travel time and distance from each origin to all destinations.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>The start time of day for the analysis. No value indicates a time neutral analysis.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Point, line, and polygon barriers to use in the OD Cost Matrix analysis.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>When you solve a network analysis, the input points must "locate" on the network used for the analysis. When chunking your inputs to solve in parallel, inputs may be used many times. Rather than calculating the network location fields for each input every time it is used, it is more efficient to calculate all the network location fields up front and re-use them. Set this parameter to True to pre-calculate the network location fields. This is recommended for every situation unless:</SPAN></P><UL><LI><P><SPAN> You are using a portal URL as the network data source. In this case, pre-calculating network locations is not possible, and the parameter is hidden.</SPAN></P></LI><LI><P><SPAN> You have already pre-calculated the network location fields using the network dataset and travel mode you are using for this analysis. In this case, you can save time by not precalculating them again.</SPAN></P></LI></UL></DIV></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><P><SPAN>Whether to spatially sort origins and destinations prior to commencing the parallel solve. Using sorted data will improve the efficiency of the chunking behavior significantly, and it may reduce the number of credits consumed if you're using a service that charges credits. If your input data is already sorted, then sorting is not useful, and you should set this parameter to false. Otherwise, you should set his parameter to true. Note, however, that spatial sorting is only available if you have the Advanced license. Spatial sorting will be skipped automatically if you don't have the necessary license, and the parameter will be hidden in the tool dialog.</SPAN></P></DIV></DIV><DIV STYLE="text-align:Left;"><DIV><DIV><P><SPAN>Sample script tool to solve a large OD Cost Matrix problem by chunking up the data and solving the chunks in parallel.</SPAN></P></DIV></DIV></DIV>Solve Large OD Cost MatrixNetwork AnalystOD Cost MatrixlargeoriginsdestinationsArcToolbox Tool20230525 3 | -------------------------------------------------------------------------------- /conference-slides/DevSummit2020_SolvingLargeTransportationAnalysisProblems.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/conference-slides/DevSummit2020_SolvingLargeTransportationAnalysisProblems.pdf -------------------------------------------------------------------------------- /conference-slides/DevSummit2021_SolvingLargeTransportationAnalysisProblems.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/conference-slides/DevSummit2021_SolvingLargeTransportationAnalysisProblems.pdf -------------------------------------------------------------------------------- /conference-slides/DevSummit2022_SolvingLargeTransportationAnalysisProblems.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/conference-slides/DevSummit2022_SolvingLargeTransportationAnalysisProblems.pdf -------------------------------------------------------------------------------- /images/CalculateLocations_SQL_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/CalculateLocations_SQL_1.png -------------------------------------------------------------------------------- /images/CalculateLocations_SQL_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/CalculateLocations_SQL_2.png -------------------------------------------------------------------------------- /images/ParallelCalculateLocations_Dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/ParallelCalculateLocations_Dialog.png -------------------------------------------------------------------------------- /images/ParallelCalculateLocations_SQL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/ParallelCalculateLocations_SQL.png -------------------------------------------------------------------------------- /images/SolveLargeAnalysisWithKnownODPairs_ManyToMany_Dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/SolveLargeAnalysisWithKnownODPairs_ManyToMany_Dialog.png -------------------------------------------------------------------------------- /images/SolveLargeAnalysisWithKnownODPairs_OneToOne_Dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/SolveLargeAnalysisWithKnownODPairs_OneToOne_Dialog.png -------------------------------------------------------------------------------- /images/SolveLargeODCostMatrix_Dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/images/SolveLargeODCostMatrix_Dialog.png -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Apache License - 2.0 2 | 3 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 4 | 5 | 1. Definitions. 6 | 7 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 8 | 9 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 10 | 11 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control 12 | with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management 13 | of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 14 | ownership of such entity. 15 | 16 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 17 | 18 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, 19 | and configuration files. 20 | 21 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to 22 | compiled object code, generated documentation, and conversions to other media types. 23 | 24 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice 25 | that is included in or attached to the work (an example is provided in the Appendix below). 26 | 27 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the 28 | editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes 29 | of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, 30 | the Work and Derivative Works thereof. 31 | 32 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work 33 | or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual 34 | or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of 35 | electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on 36 | electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for 37 | the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing 38 | by the copyright owner as "Not a Contribution." 39 | 40 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and 41 | subsequently incorporated within the Work. 42 | 43 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, 44 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, 45 | publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 46 | 47 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, 48 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, 49 | sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are 50 | necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was 51 | submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work 52 | or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You 53 | under this License for that Work shall terminate as of the date such litigation is filed. 54 | 55 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, 56 | and in Source or Object form, provided that You meet the following conditions: 57 | 58 | 1. You must give any other recipients of the Work or Derivative Works a copy of this License; and 59 | 60 | 2. You must cause any modified files to carry prominent notices stating that You changed the files; and 61 | 62 | 3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices 63 | from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 64 | 65 | 4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a 66 | readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the 67 | Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the 68 | Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever 69 | such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. 70 | You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, 71 | provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to 72 | Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your 73 | modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with 74 | the conditions stated in this License. 75 | 76 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You 77 | to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, 78 | nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 79 | 80 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except 81 | as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 82 | 83 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides 84 | its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, 85 | any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for 86 | determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under 87 | this License. 88 | 89 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required 90 | by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, 91 | including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the 92 | use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or 93 | any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 94 | 95 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a 96 | fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting 97 | such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree 98 | to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your 99 | accepting any such warranty or additional liability. 100 | 101 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /od_config.py: -------------------------------------------------------------------------------- 1 | """Defines OD Cost matrix solver object properties that are not specified 2 | in the tool dialog. 3 | 4 | A list of OD cost matrix solver properties is documented here: 5 | https://pro.arcgis.com/en/pro-app/latest/arcpy/network-analyst/odcostmatrix.htm 6 | 7 | You can include any of them in the dictionary in this file, and the tool will 8 | use them. However, travelMode, timeUnits, distanceUnits, defaultImpedanceCutoff, 9 | defaultDestinationCount, and timeOfDay will be ignored because they are specified 10 | in the tool dialog. 11 | 12 | Copyright 2022 Esri 13 | Licensed under the Apache License, Version 2.0 (the "License"); 14 | you may not use this file except in compliance with the License. 15 | You may obtain a copy of the License at 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, 19 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | See the License for the specific language governing permissions and 21 | limitations under the License. 22 | """ 23 | import arcpy 24 | 25 | # These properties are set by the tool dialog or can be specified as command line arguments. Do not set the values for 26 | # these properties in the OD_PROPS dictionary below because they will be ignored. 27 | OD_PROPS_SET_BY_TOOL = [ 28 | "travelMode", "timeUnits", "distanceUnits", "defaultImpedanceCutoff", "defaultDestinationCount", "timeOfDay"] 29 | 30 | # You can customize these properties to your needs, and the parallel OD cost matrix calculations will use them. 31 | OD_PROPS = { 32 | "accumulateAttributeNames": [], 33 | "allowSaveLayerFile": False, 34 | "ignoreInvalidLocations": True, 35 | "lineShapeType": arcpy.nax.LineShapeType.NoLine, 36 | "overrides": "", 37 | # "searchQuery": [], # This parameter is very network specific. Only uncomment if you are using it. 38 | "searchTolerance": 5000, 39 | "searchToleranceUnits": arcpy.nax.DistanceUnits.Meters, 40 | "timeZone": arcpy.nax.TimeZoneUsage.LocalTimeAtLocations, 41 | } 42 | -------------------------------------------------------------------------------- /parallel_calculate_locations.py: -------------------------------------------------------------------------------- 1 | """Calculate the network locations for a large dataset by chunking the 2 | inputs and solving in parallel. 3 | 4 | This is a sample script users can modify to fit their specific needs. 5 | 6 | Note: Unlike in the core Calculate Locations tool, this tool generates 7 | a new feature class instead of merely adding fields to the original. 8 | A new feature class must be generated during the parallel processing, 9 | and as a result, the ObjectIDs may change, so we ask the user to specify 10 | an output feature class path instead of overwriting the original. If you 11 | need the original ObjectIDs, please calculate an additional field to track 12 | them before calling this tool. We also do this to avoid accidentally deleting 13 | the user's original data if the tool errors. 14 | 15 | This script is intended to be called as a subprocess from a other scripts 16 | so that it can launch parallel processes with concurrent.futures. It must be 17 | called as a subprocess because the main script tool process, when running 18 | within ArcGIS Pro, cannot launch parallel subprocesses on its own. 19 | 20 | This script should not be called directly from the command line. 21 | 22 | Copyright 2024 Esri 23 | Licensed under the Apache License, Version 2.0 (the "License"); 24 | you may not use this file except in compliance with the License. 25 | You may obtain a copy of the License at 26 | http://www.apache.org/licenses/LICENSE-2.0 27 | Unless required by applicable law or agreed to in writing, software 28 | distributed under the License is distributed on an "AS IS" BASIS, 29 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 30 | See the License for the specific language governing permissions and 31 | limitations under the License. 32 | """ 33 | # pylint: disable=logging-fstring-interpolation 34 | import os 35 | import uuid 36 | import logging 37 | import shutil 38 | import time 39 | import traceback 40 | import argparse 41 | 42 | import arcpy 43 | 44 | import helpers 45 | 46 | DELETE_INTERMEDIATE_OUTPUTS = True # Set to False for debugging purposes 47 | 48 | # Change logging.INFO to logging.DEBUG to see verbose debug messages 49 | LOG_LEVEL = logging.INFO 50 | 51 | 52 | class LocationCalculator( 53 | helpers.JobFolderMixin, helpers.LoggingMixin, helpers.MakeNDSLayerMixin 54 | ): # pylint:disable = too-many-instance-attributes 55 | """Used for calculating network locations for a designated chunk of the input datasets.""" 56 | 57 | def __init__(self, **kwargs): 58 | """Initialize the location calculator for the given inputs. 59 | 60 | Expected arguments: 61 | - input_fc 62 | - network_data_source 63 | - travel_mode 64 | - search_tolerance 65 | - search_criteria 66 | - search_query 67 | - scratch_folder 68 | """ 69 | self.input_fc = kwargs["input_fc"] 70 | self.network_data_source = kwargs["network_data_source"] 71 | self.travel_mode = kwargs["travel_mode"] 72 | self.scratch_folder = kwargs["scratch_folder"] 73 | self.search_tolerance = kwargs.get("search_tolerance", None) 74 | self.search_criteria = kwargs.get("search_criteria", None) 75 | self.search_query = kwargs.get("search_query", None) 76 | 77 | # Create a job ID and a folder for this job 78 | self._create_job_folder() 79 | 80 | # Setup the class logger. Logs for each parallel process are not written to the console but instead to a 81 | # process-specific log file. 82 | self.setup_logger("CalcLocs") 83 | 84 | # Create a network dataset layer if needed 85 | self._make_nds_layer() 86 | 87 | # Define output feature class path for this chunk (set during feature selection) 88 | self.out_fc = None 89 | 90 | # Prepare a dictionary to store info about the analysis results 91 | self.job_result = { 92 | "jobId": self.job_id, 93 | "jobFolder": self.job_folder, 94 | "outputFC": "", 95 | "oidRange": None, 96 | "logFile": self.log_file 97 | } 98 | 99 | def _subset_inputs(self, oid_range): 100 | """Create a layer from the input feature class that contains only the OIDs for this chunk. 101 | 102 | Args: 103 | oid_range (list): Input feature class ObjectID range to select for this chunk 104 | """ 105 | # Copy the subset of features in this OID range to a feature class in the job gdb so we can calculate locations 106 | # on it without interference from other parallel processes 107 | self.logger.debug("Subsetting features for this chunk...") 108 | out_gdb = self._create_output_gdb() 109 | self.out_fc = os.path.join(out_gdb, f"Locs_{oid_range[0]}_{oid_range[1]}") 110 | oid_field_name = arcpy.Describe(self.input_fc).oidFieldName 111 | where_clause = ( 112 | f"{oid_field_name} >= {oid_range[0]} " 113 | f"And {oid_field_name} <= {oid_range[1]}" 114 | ) 115 | self.logger.debug(f"Where clause: {where_clause}") 116 | arcpy.conversion.FeatureClassToFeatureClass( 117 | self.input_fc, 118 | os.path.dirname(self.out_fc), 119 | os.path.basename(self.out_fc), 120 | where_clause 121 | ) 122 | 123 | def calculate_locations(self, oid_range): 124 | """Calculate locations for a chunk of the input feature class with the designated OID range.""" 125 | self._subset_inputs(oid_range) 126 | self.logger.debug("Calculating locations...") 127 | helpers.run_gp_tool( 128 | self.logger, 129 | arcpy.na.CalculateLocations, 130 | [ 131 | self.out_fc, 132 | self.network_data_source 133 | ], { 134 | "search_tolerance": self.search_tolerance, 135 | "search_criteria": self.search_criteria, 136 | "search_query": self.search_query, 137 | "travel_mode": self.travel_mode 138 | } 139 | ) 140 | self.job_result["outputFC"] = self.out_fc 141 | self.job_result["oidRange"] = tuple(oid_range) 142 | 143 | 144 | def calculate_locations_for_chunk(chunk, calc_locs_settings): 145 | """Calculate locations for a range of OIDs in the input dataset. 146 | 147 | Args: 148 | chunk (list): OID range to calculate locations for. Specified as a list of [start range, end range], inclusive. 149 | calc_locs_settings (dict): Dictionary of kwargs for the LocationCalculator class. 150 | 151 | Returns: 152 | dict: Dictionary of job results for the chunk 153 | """ 154 | location_calculator = LocationCalculator(**calc_locs_settings) 155 | location_calculator.calculate_locations(chunk) 156 | location_calculator.teardown_logger() 157 | return location_calculator.job_result 158 | 159 | 160 | class ParallelLocationCalculator: 161 | """Calculate network locations for a large dataset by chunking the dataset and calculating in parallel.""" 162 | 163 | def __init__( # pylint: disable=too-many-locals, too-many-arguments 164 | self, logger, input_features, output_features, network_data_source, chunk_size, max_processes, 165 | travel_mode=None, search_tolerance=None, search_criteria=None, search_query=None 166 | ): 167 | """Calculate network locations for the input features in parallel. 168 | 169 | Run the Calculate Locations tool on chunks of the input dataset in parallel and and recombine the results. 170 | Refer to the Calculate Locations tool documentation for more information about the input parameters. 171 | https://pro.arcgis.com/en/pro-app/latest/tool-reference/network-analyst/calculate-locations.htm 172 | 173 | Args: 174 | logger (logging.logger): Logger class to use for messages that get written to the GP window. Set up using 175 | helpers.configure_global_logger(). 176 | input_features (str): Catalog path to input features to calculate locations for 177 | output_features (str): Catalog path to the location where the output updated feature class will be saved. 178 | Unlike in the core Calculate Locations tool, this tool generates a new feature class instead of merely 179 | adding fields to the original. A new feature class must be generated during the parallel processing, 180 | and as a result, the ObjectIDs may change, so we ask the user to specify an output feature class path 181 | instead of overwriting the original. If you need the original ObjectIDs, please calculate an additional 182 | field to track them before calling this tool. 183 | network_data_source (str): Network data source catalog path 184 | chunk_size (int): Maximum features to be processed in one chunk 185 | max_processes (int): Maximum number of parallel processes allowed 186 | travel_mode (str, optional): String-based representation of a travel mode (name or JSON) 187 | search_tolerance (str, optional): Linear Unit string representing the search distance to use when locating 188 | search_criteria (list, optional): Defines the network sources that can be used for locating 189 | search_query (list, optional): Defines queries to use per network source when locating. 190 | """ 191 | self.input_features = input_features 192 | self.output_features = output_features 193 | self.max_processes = max_processes 194 | 195 | # Set up logger that will write to the GP window 196 | self.logger = logger 197 | 198 | # Scratch folder to store intermediate outputs from the OD Cost Matrix processes 199 | unique_id = uuid.uuid4().hex 200 | self.scratch_folder = os.path.join( 201 | arcpy.env.scratchFolder, "CalcLocs_" + unique_id) # pylint: disable=no-member 202 | self.logger.info(f"Intermediate outputs will be written to {self.scratch_folder}.") 203 | os.mkdir(self.scratch_folder) 204 | 205 | # Dictionary of static input settings to send to the parallel location calculator 206 | self.calc_locs_inputs = { 207 | "input_fc": self.input_features, 208 | "network_data_source": network_data_source, 209 | "travel_mode": travel_mode, 210 | "search_tolerance": search_tolerance, 211 | "search_criteria": search_criteria, 212 | "search_query": search_query, 213 | "scratch_folder": self.scratch_folder 214 | } 215 | 216 | # List of intermediate output feature classes created by each process 217 | self.temp_out_fcs = {} 218 | 219 | # Construct OID ranges for the input data chunks 220 | self.ranges = helpers.get_oid_ranges_for_input(self.input_features, chunk_size) 221 | 222 | def calc_locs_in_parallel(self): 223 | """Calculate locations in parallel.""" 224 | # Calculate locations in parallel 225 | job_results = helpers.run_parallel_processes( 226 | self.logger, calculate_locations_for_chunk, [self.calc_locs_inputs], self.ranges, 227 | len(self.ranges), self.max_processes, 228 | "Calculating locations", "Calculate Locations" 229 | ) 230 | for result in job_results: 231 | # Parse the results dictionary and store components for post-processing. 232 | # Store the ranges as dictionary keys to facilitate sorting. 233 | self.temp_out_fcs[result["oidRange"]] = result["outputFC"] 234 | 235 | # Rejoin the chunked feature classes into one. 236 | self.logger.info("Rejoining chunked data...") 237 | self._rejoin_chunked_output() 238 | 239 | # Clean up 240 | # Delete the job folders if the job succeeded 241 | if DELETE_INTERMEDIATE_OUTPUTS: 242 | self.logger.info("Deleting intermediate outputs...") 243 | try: 244 | shutil.rmtree(self.scratch_folder, ignore_errors=True) 245 | except Exception: # pylint: disable=broad-except 246 | # If deletion doesn't work, just throw a warning and move on. This does not need to kill the tool. 247 | self.logger.warning( 248 | f"Unable to delete intermediate Calculate Locations output folder {self.scratch_folder}.") 249 | 250 | self.logger.info("Finished calculating locations in parallel.") 251 | 252 | def _rejoin_chunked_output(self): 253 | """Merge the chunks into a single feature class. 254 | 255 | Create an empty final output feature class and populate it using InsertCursor, as this tends to be faster than 256 | using the Merge geoprocessing tool. 257 | """ 258 | self.logger.debug("Creating output feature class...") 259 | 260 | # Handle ridiculously huge outputs that may exceed the number of rows allowed in a 32-bit OID feature class 261 | kwargs = {} 262 | if helpers.arcgis_version >= "3.2": # 64-bit OIDs were introduced in ArcGIS Pro 3.2. 263 | num_inputs = int(arcpy.management.GetCount(self.input_features).getOutput(0)) 264 | if num_inputs > helpers.MAX_ALLOWED_FC_ROWS_32BIT: 265 | # Use a 64bit OID field in the output feature class 266 | kwargs = {"oid_type": "64_BIT"} 267 | 268 | # Create the final output feature class 269 | template_fc = self.temp_out_fcs[tuple(self.ranges[0])] 270 | desc = arcpy.Describe(template_fc) 271 | helpers.run_gp_tool( 272 | self.logger, 273 | arcpy.management.CreateFeatureclass, [ 274 | os.path.dirname(self.output_features), 275 | os.path.basename(self.output_features), 276 | "POINT", 277 | template_fc, # template feature class to transfer full schema 278 | "SAME_AS_TEMPLATE", 279 | "SAME_AS_TEMPLATE", 280 | desc.spatialReference 281 | ], 282 | kwargs 283 | ) 284 | 285 | # Insert the rows from all the individual output feature classes into the final output 286 | self.logger.debug("Inserting rows into output feature class from output chunks...") 287 | fields = ["SHAPE@"] + [f.name for f in desc.fields] 288 | with arcpy.da.InsertCursor(self.output_features, fields) as cur: # pylint: disable=no-member 289 | # Get rows from the output feature class from each chunk in the original order 290 | for chunk in self.ranges: 291 | for row in arcpy.da.SearchCursor(self.temp_out_fcs[tuple(chunk)], fields): # pylint: disable=no-member 292 | cur.insertRow(row) 293 | 294 | 295 | def launch_parallel_calc_locs(): 296 | """Read arguments passed in via subprocess and run the parallel calculate locations. 297 | 298 | This script is intended to be called via subprocess via a client module. Users should not call this script 299 | directly from the command line. 300 | 301 | We must launch this script via subprocess in order to support parallel processing from an ArcGIS Pro script tool, 302 | which cannot do parallel processing directly. 303 | """ 304 | # Create the parser 305 | parser = argparse.ArgumentParser(description=globals().get("__doc__", ""), fromfile_prefix_chars='@') 306 | 307 | # Define Arguments supported by the command line utility 308 | 309 | # --input-features parameter 310 | help_string = "The full catalog path to the input features to calculate locations for." 311 | parser.add_argument( 312 | "-if", "--input-features", action="store", dest="input_features", help=help_string, required=True) 313 | 314 | # --output-features parameter 315 | help_string = "The full catalog path to the output features." 316 | parser.add_argument( 317 | "-of", "--output-features", action="store", dest="output_features", help=help_string, required=True) 318 | 319 | # --network-data-source parameter 320 | help_string = "The full catalog path to the network dataset that will be used for calculating locations." 321 | parser.add_argument( 322 | "-n", "--network-data-source", action="store", dest="network_data_source", help=help_string, required=True) 323 | 324 | # --chunk-size parameter 325 | help_string = "Maximum number of features that can be in one chunk for parallel processing." 326 | parser.add_argument( 327 | "-ch", "--chunk-size", action="store", dest="chunk_size", type=int, help=help_string, required=True) 328 | 329 | # --max-processes parameter 330 | help_string = "Maximum number parallel processes to use for calculating locations." 331 | parser.add_argument( 332 | "-mp", "--max-processes", action="store", dest="max_processes", type=int, help=help_string, required=True) 333 | 334 | # --travel-mode parameter 335 | help_string = ( 336 | "The name or JSON string representation of the travel mode from the network data source that will be used for " 337 | "calculating locations." 338 | ) 339 | parser.add_argument("-tm", "--travel-mode", action="store", dest="travel_mode", help=help_string, required=False) 340 | 341 | # --search-tolerance parameter 342 | help_string = "Linear Unit string representing the search distance to use when locating." 343 | parser.add_argument( 344 | "-st", "--search-tolerance", action="store", dest="search_tolerance", help=help_string, required=False) 345 | 346 | # --search-criteria parameter 347 | help_string = "Defines the network sources that can be used for locating." 348 | parser.add_argument( 349 | "-sc", "--search-criteria", action="store", dest="search_criteria", help=help_string, required=False) 350 | 351 | # --search-query parameter 352 | help_string = "Defines queries to use per network source when locating." 353 | parser.add_argument( 354 | "-sq", "--search-query", action="store", dest="search_query", help=help_string, required=False) 355 | 356 | try: 357 | logger = helpers.configure_global_logger(LOG_LEVEL) 358 | 359 | # Get arguments as dictionary. 360 | args = vars(parser.parse_args()) 361 | args["logger"] = logger 362 | 363 | # Initialize a parallel location calculator class 364 | cl_calculator = ParallelLocationCalculator(**args) 365 | # Calculate network locations in parallel chunks 366 | start_time = time.time() 367 | cl_calculator.calc_locs_in_parallel() 368 | logger.info(f"Parallel Calculate Locations completed in {round((time.time() - start_time) / 60, 2)} minutes") 369 | 370 | except Exception: # pylint: disable=broad-except 371 | logger.error("Error in parallelization subprocess.") 372 | errs = traceback.format_exc().splitlines() 373 | for err in errs: 374 | logger.error(err) 375 | raise 376 | 377 | finally: 378 | helpers.teardown_logger(logger) 379 | 380 | if __name__ == "__main__": 381 | # This script should always be launched via subprocess as if it were being called from the command line. 382 | with arcpy.EnvManager(overwriteOutput=True): 383 | launch_parallel_calc_locs() 384 | -------------------------------------------------------------------------------- /rt_config.py: -------------------------------------------------------------------------------- 1 | """Defines Route solver object properties that are not specified 2 | in the tool dialog. 3 | 4 | A list of Route solver properties is documented here: 5 | https://pro.arcgis.com/en/pro-app/latest/arcpy/network-analyst/route.htm 6 | 7 | You can include any of them in the dictionary in this file, and the tool will 8 | use them. However, travelMode, timeUnits, distanceUnits, and timeOfDay 9 | will be ignored because they are specified in the tool dialog. 10 | 11 | Copyright 2022 Esri 12 | Licensed under the Apache License, Version 2.0 (the "License"); 13 | you may not use this file except in compliance with the License. 14 | You may obtain a copy of the License at 15 | http://www.apache.org/licenses/LICENSE-2.0 16 | Unless required by applicable law or agreed to in writing, software 17 | distributed under the License is distributed on an "AS IS" BASIS, 18 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | See the License for the specific language governing permissions and 20 | limitations under the License. 21 | """ 22 | import arcpy 23 | 24 | # These properties are set by the tool dialog or can be specified as command line arguments. Do not set the values for 25 | # these properties in the RT_PROPS dictionary below because they will be ignored. 26 | RT_PROPS_SET_BY_TOOL = ["travelMode", "timeUnits", "distanceUnits", "timeOfDay"] 27 | 28 | # You can customize these properties to your needs, and the parallel Route calculations will use them. 29 | RT_PROPS = { 30 | 'accumulateAttributeNames': [], 31 | 'allowSaveLayerFile': False, 32 | 'allowSaveRouteData': False, 33 | 'directionsDistanceUnits': arcpy.nax.DistanceUnits.Kilometers, 34 | 'directionsLanguage': "en", 35 | 'directionsStyle': arcpy.nax.DirectionsStyle.Desktop, 36 | 'findBestSequence': False, 37 | 'ignoreInvalidLocations': True, 38 | 'overrides': "", 39 | 'preserveFirstStop': False, 40 | 'preserveLastStop': False, 41 | 'returnDirections': False, 42 | 'returnRouteEdges': True, 43 | 'returnRouteJunctions': False, 44 | 'returnRouteTurns': False, 45 | 'returnToStart': False, 46 | 'routeShapeType': arcpy.nax.RouteShapeType.TrueShapeWithMeasures, 47 | # 'searchQuery': "", # This parameter is very network specific. Only uncomment if you are using it. 48 | 'searchTolerance': 5000, 49 | 'searchToleranceUnits': arcpy.nax.DistanceUnits.Meters, 50 | 'timeZone': arcpy.nax.TimeZoneUsage.LocalTimeAtLocations, 51 | 'timeZoneForTimeWindows': arcpy.nax.TimeZoneUsage.LocalTimeAtLocations, 52 | } 53 | -------------------------------------------------------------------------------- /unittests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Esri/large-network-analysis-tools/b8503c5d606260f53acad6fd3bff612c242e2da4/unittests/__init__.py -------------------------------------------------------------------------------- /unittests/input_data_helper.py: -------------------------------------------------------------------------------- 1 | """Helper for unit tests to create required inputs. 2 | 3 | Copyright 2024 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | import os 15 | import csv 16 | import arcpy 17 | 18 | 19 | def get_tract_centroids_with_store_id_fc(sf_gdb): 20 | """Create the TractCentroids_wStoreID feature class in the SanFrancisco.gdb/Analysis for use in unit tests.""" 21 | new_fc = os.path.join(sf_gdb, "Analysis", "TractCentroids_wStoreID") 22 | if arcpy.Exists(new_fc): 23 | # The feature class exists already, so there's no need to do anything. 24 | return new_fc 25 | # Copy the tutorial dataset's TractCentroids feature class to the new feature class 26 | print(f"Creating {new_fc} for test input...") 27 | orig_fc = os.path.join(sf_gdb, "Analysis", "TractCentroids") 28 | if not arcpy.Exists(orig_fc): 29 | raise ValueError(f"{orig_fc} is missing.") 30 | arcpy.management.Copy(orig_fc, new_fc) 31 | # Add and populate the StoreID field 32 | # Also add a pre-populated CurbApproach field to test field transfer 33 | arcpy.management.AddField(new_fc, "StoreID", "TEXT", field_length=8) 34 | arcpy.management.AddField(new_fc, "CurbApproach", "SHORT") 35 | store_ids = [ # Pre-assigned store IDs to add to TractCentroids 36 | 'Store_6', 'Store_6', 'Store_11', 'Store_11', 'Store_11', 'BadStore', 'Store_11', 'Store_11', 'Store_11', '', 37 | 'Store_11', 'Store_11', 'Store_6', 'Store_11', 'Store_11', 'Store_11', 'Store_11', 'Store_1', 'Store_7', 38 | 'Store_1', 'Store_1', 'Store_2', 'Store_2', 'Store_2', 'Store_1', 'Store_2', 'Store_7', 'Store_1', 'Store_2', 39 | 'Store_2', 'Store_7', 'Store_7', 'Store_7', 'Store_4', 'Store_4', 'Store_3', 'Store_3', 'Store_3', 'Store_3', 40 | 'Store_19', 'Store_14', 'Store_19', 'Store_19', 'Store_14', 'Store_19', 'Store_16', 'Store_14', 'Store_14', 41 | 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 42 | 'Store_14', 'Store_7', 'Store_7', 'Store_7', 'Store_7', 'Store_7', 'Store_7', 'Store_7', 'Store_7', 'Store_12', 43 | 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_7', 'Store_12', 'Store_12', 44 | 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_12', 'Store_4', 'Store_12', 45 | 'Store_4', 'Store_4', 'Store_4', 'Store_4', 'Store_4', 'Store_4', 'Store_13', 'Store_13', 'Store_13', 46 | 'Store_13', 'Store_5', 'Store_13', 'Store_13', 'Store_5', 'Store_5', 'Store_12', 'Store_12', 'Store_14', 47 | 'Store_14', 'Store_12', 'Store_12', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_14', 'Store_12', 48 | 'Store_14', 'Store_12', 'Store_14', 'Store_14', 'Store_14', None, 'Store_12', 'Store_14', 'Store_14', 49 | 'Store_14', 'Store_14', 'Store_3', 'Store_2', 'Store_3', 'Store_3', 'Store_3', 'Store_3', 'Store_4', 'Store_3', 50 | 'Store_2', 'Store_3', 'Store_16', 'Store_3', 'Store_3', 'Store_3', 'Store_3', 'Store_18', 'Store_16', 51 | 'Store_16', 'Store_16', 'Store_15', 'Store_16', 'Store_16', 'Store_14', 'Store_16', 'Store_3', 'Store_3', 52 | 'Store_13', 'Store_3', 'Store_16', 'Store_16', 'Store_16', 'Store_16', 'Store_14', 'Store_14', 'Store_16', 53 | 'Store_16', 'Store_16', 'Store_16', 'Store_16', 'Store_17', 'Store_15', 'Store_17', 'Store_17', 'Store_17', 54 | 'Store_17', 'Store_15', 'Store_15', 'Store_15', 'Store_3', 'Store_15', 'Store_15', 'Store_15', 'Store_15', 55 | 'Store_15', 'Store_15', 'Store_15', 'Store_15', 'Store_15', 'Store_15', 'Store_16', 'Store_19', 'Store_19', 56 | 'Store_19', 'Store_15', 'Store_19', 'Store_15', 'Store_16', 'Store_19', 'Store_19', 'Store_19', 'Store_18', 57 | 'Store_18', 'Store_15', 'Store_18', 'Store_18', 'Store_18', 'Store_15', 'Store_18', 'Store_17', 'Store_18', 58 | 'Store_15', 'Store_15', 'Store_16', 'Store_19', 'Store_15'] 59 | with arcpy.da.UpdateCursor(new_fc, ["StoreID", "CurbApproach"]) as cur: # pylint: disable=no-member 60 | idx = 0 61 | for _ in cur: 62 | cur.updateRow([store_ids[idx], 2]) 63 | idx += 1 64 | return new_fc 65 | 66 | 67 | def get_tract_centroids_with_cutoff(sf_gdb): 68 | """Create the TractCentroids_Cutoff feature class in the SanFrancisco.gdb/Analysis for use in unit tests.""" 69 | new_fc = os.path.join(sf_gdb, "Analysis", "TractCentroids_Cutoff") 70 | if arcpy.Exists(new_fc): 71 | # The feature class exists already, so there's no need to do anything. 72 | return new_fc 73 | # Copy the tutorial dataset's TractCentroids feature class to the new feature class 74 | print(f"Creating {new_fc} for test input...") 75 | orig_fc = os.path.join(sf_gdb, "Analysis", "TractCentroids") 76 | if not arcpy.Exists(orig_fc): 77 | raise ValueError(f"{orig_fc} is missing.") 78 | arcpy.management.Copy(orig_fc, new_fc) 79 | # Add and populate the Cutoff field 80 | arcpy.management.AddField(new_fc, "Cutoff", "DOUBLE") 81 | with arcpy.da.UpdateCursor(new_fc, ["NAME", "Cutoff"]) as cur: # pylint: disable=no-member 82 | # Give 060816029.00 a cutoff value of 15 and leave the rest null 83 | for row in cur: 84 | if row[0] == "060816029.00": 85 | cur.updateRow([row[0], 15]) 86 | return new_fc 87 | 88 | 89 | def get_stores_with_dest_count(sf_gdb): 90 | """Create the Stores_DestCount feature class in the SanFrancisco.gdb/Analysis for use in unit tests.""" 91 | new_fc = os.path.join(sf_gdb, "Analysis", "Stores_DestCount") 92 | if arcpy.Exists(new_fc): 93 | # The feature class exists already, so there's no need to do anything. 94 | return new_fc 95 | # Copy the tutorial dataset's Stores feature class to the new feature class 96 | print(f"Creating {new_fc} for test input...") 97 | orig_fc = os.path.join(sf_gdb, "Analysis", "Stores") 98 | if not arcpy.Exists(orig_fc): 99 | raise ValueError(f"{orig_fc} is missing.") 100 | arcpy.management.Copy(orig_fc, new_fc) 101 | # Add and populate the Cutoff field 102 | arcpy.management.AddField(new_fc, "TargetDestinationCount", "LONG") 103 | with arcpy.da.UpdateCursor(new_fc, ["NAME", "TargetDestinationCount"]) as cur: # pylint: disable=no-member 104 | # Give Store_1 a TargetDestinationCount of 3 and Store_2 a TargetDestinationCount of 2 105 | # and leave the rest as null 106 | for row in cur: 107 | if row[0] == "Store_1": 108 | cur.updateRow([row[0], 3]) 109 | if row[0] == "Store_2": 110 | cur.updateRow([row[0], 2]) 111 | return new_fc 112 | 113 | 114 | def get_od_pair_csv(input_data_folder): 115 | """Create the od_pairs.csv input file in the input data folder for use in unit testing.""" 116 | od_pair_file = os.path.join(input_data_folder, "od_pairs.csv") 117 | if os.path.exists(od_pair_file): 118 | # The OD pair file already exists, so no need to do anything. 119 | return od_pair_file 120 | print(f"Creating {od_pair_file} for test input...") 121 | od_pairs = [ 122 | ["06075011400", "Store_13"], 123 | ["06075011400", "Store_19"], 124 | ["06075011400", "Store_11"], 125 | ["06075021800", "Store_9"], 126 | ["06075021800", "Store_12"], 127 | ["06075013000", "Store_3"], 128 | ["06075013000", "Store_10"], 129 | ["06075013000", "Store_1"], 130 | ["06075013000", "Store_8"], 131 | ["06075013000", "Store_12"], 132 | ["06081602500", "Store_12"], 133 | ["06081602500", "Store_25"], 134 | ["06081602500", "Store_9"], 135 | ["06081602500", "Store_17"], 136 | ["06081602500", "Store_21"], 137 | ["06075030400", "Store_7"], 138 | ["06075030400", "Store_5"], 139 | ["06075030400", "Store_21"], 140 | ["06075030400", "Store_19"], 141 | ["06075030400", "Store_23"], 142 | ["06075045200", "Store_1"], 143 | ["06075045200", "Store_5"], 144 | ["06075045200", "Store_6"], 145 | ["06075012600", "Store_19"], 146 | ["06075012600", "Store_5"], 147 | ["06075012600", "Store_23"], 148 | ["06075012600", "Store_15"], 149 | ["06075060700", "Store_19"], 150 | ["06081601400", "Store_7"], 151 | ["06081601400", "Store_15"], 152 | ["06081601400", "Store_2"], 153 | ["06075023001", "Store_22"], 154 | ["06075032800", "Store_25"], 155 | ["06081601800", "Store_13"], 156 | ["06075013100", "Store_25"], 157 | ["06075013100", "Store_9"], 158 | ["06075013100", "Store_23"], 159 | ["06081600600", "Store_22"], 160 | ["06081600600", "Store_12"], 161 | ["06081600600", "Store_1"], 162 | ["06081600600", "Store_21"], 163 | ["06075012000", "Store_22"], 164 | ["06075031400", "Store_20"], 165 | ["06075031400", "Store_24"], 166 | ["06081601000", "Store_2"], 167 | ["06075026004", "Store_16"], 168 | ["06075026004", "Store_7"], 169 | ["06075020300", "Store_17"], 170 | ["06075020300", "Store_13"], 171 | ["06075060502", "Store_18"], 172 | ["06075011000", "Store_21"], 173 | ["06075011000", "Store_19"], 174 | ["06075011000", "Store_12"], 175 | ["06075011000", "Store_22"], 176 | ["06075026404", "Store_17"], 177 | ["06075026404", "Store_9"], 178 | ["06081601601", "Store_9"], 179 | ["06075021500", "Store_22"], 180 | ["06075021500", "Store_9"], 181 | ["06075026302", "Store_21"], 182 | ["06075026302", "Store_15"], 183 | ["06075026302", "Store_23"], 184 | ["06075026302", "Store_24"] 185 | ] 186 | with open(od_pair_file, "w", newline='', encoding="utf-8") as f: 187 | w = csv.writer(f) 188 | w.writerows(od_pairs) 189 | return od_pair_file 190 | 191 | 192 | def get_od_pairs_fgdb_table(sf_gdb): 193 | """Create the ODPairs fgdb input table in SanFrancisco.gdb for use in unit testing.""" 194 | od_pair_table = os.path.join(sf_gdb, "ODPairs") 195 | if arcpy.Exists(od_pair_table): 196 | # The OD pair table already exists, so no need to do anything. 197 | return od_pair_table 198 | input_data_folder = os.path.dirname(sf_gdb) 199 | od_pair_csv = get_od_pair_csv(input_data_folder) 200 | print(f"Creating {od_pair_table} for test input...") 201 | arcpy.management.CreateTable(sf_gdb, "ODPairs") 202 | arcpy.management.AddFields( 203 | od_pair_table, 204 | [["OriginID", "TEXT", "OriginID", 25], ["DestinationID", "TEXT", "DestinationID", 45]] 205 | ) 206 | with arcpy.da.InsertCursor(od_pair_table, ["OriginID", "DestinationID"]) as cur: # pylint: disable=no-member 207 | with open(od_pair_csv, "r", encoding="utf-8") as f: 208 | for row in csv.reader(f): 209 | cur.insertRow(row) 210 | 211 | return od_pair_table 212 | -------------------------------------------------------------------------------- /unittests/portal_credentials.py: -------------------------------------------------------------------------------- 1 | """Portal credentials for unit tests.""" 2 | # Do not check in your actual credentials into the repo. 3 | PORTAL_URL = "" 4 | PORTAL_USERNAME = "" 5 | PORTAL_PASSWORD = "" 6 | PORTAL_TRAVEL_MODE = "" 7 | -------------------------------------------------------------------------------- /unittests/test_ParallelCalculateLocations_tool.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the ParallelCalculateLocations script tool. The test cases focus 2 | on making sure the tool parameters work correctly. 3 | 4 | Copyright 2023 Esri 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | """ 15 | # pylint: disable=import-error, invalid-name 16 | 17 | import os 18 | import datetime 19 | import unittest 20 | import arcpy 21 | 22 | CWD = os.path.dirname(os.path.abspath(__file__)) 23 | 24 | 25 | class TestSolveLargeODCostMatrixTool(unittest.TestCase): 26 | """Test cases for the SolveLargeODCostMatrix script tool.""" 27 | 28 | @classmethod 29 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 30 | self.maxDiff = None 31 | 32 | tbx_path = os.path.join(os.path.dirname(CWD), "LargeNetworkAnalysisTools.pyt") 33 | arcpy.ImportToolbox(tbx_path) 34 | 35 | self.input_data_folder = os.path.join(CWD, "TestInput") 36 | sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 37 | self.test_points = os.path.join(sf_gdb, "Analysis", "TractCentroids") 38 | self.local_nd = os.path.join(sf_gdb, "Transportation", "Streets_ND") 39 | tms = arcpy.nax.GetTravelModes(self.local_nd) 40 | self.local_tm_time = tms["Driving Time"] 41 | 42 | # Create a unique output directory and gdb for this test 43 | self.scratch_folder = os.path.join( 44 | CWD, "TestOutput", 45 | "Output_ParallelCalcLocs_Tool_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 46 | os.makedirs(self.scratch_folder) 47 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 48 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 49 | 50 | def test_tool_defaults(self): 51 | """Test that the tool runs and works with only required parameters.""" 52 | # The input feature class should not be overwritten by this tool, but copy it first just in case. 53 | fc_to_precalculate = os.path.join(self.output_gdb, "PrecalcFC_InputD") 54 | arcpy.management.Copy(self.test_points, fc_to_precalculate) 55 | output_fc = os.path.join(self.output_gdb, "PrecalcFC_OutputD") 56 | arcpy.LargeNetworkAnalysisTools.ParallelCalculateLocations( # pylint: disable=no-member 57 | fc_to_precalculate, 58 | output_fc, 59 | self.local_nd, 60 | ) 61 | self.assertTrue(arcpy.Exists(output_fc), "Output feature class does not exist") 62 | 63 | def test_tool_nondefaults(self): 64 | """Test that the tool runs and works with all input parameters.""" 65 | # The input feature class should not be overwritten by this tool, but copy it first just in case. 66 | fc_to_precalculate = os.path.join(self.output_gdb, "PrecalcFC_InputND") 67 | arcpy.management.Copy(self.test_points, fc_to_precalculate) 68 | output_fc = os.path.join(self.output_gdb, "PrecalcFC_OutputND") 69 | arcpy.LargeNetworkAnalysisTools.ParallelCalculateLocations( # pylint: disable=no-member 70 | fc_to_precalculate, 71 | output_fc, 72 | self.local_nd, 73 | 30, 74 | 4, 75 | self.local_tm_time, 76 | "5000 Meters", 77 | ["Streets"], 78 | [["Streets", "ObjectID <> 1"]] 79 | ) 80 | self.assertTrue(arcpy.Exists(output_fc), "Output feature class does not exist") 81 | 82 | def test_error_invalid_query_sources(self): 83 | """Test for correct error when an invalid network source name is specified in the search query.""" 84 | with self.assertRaises(arcpy.ExecuteError) as ex: 85 | arcpy.LargeNetworkAnalysisTools.ParallelCalculateLocations( # pylint: disable=no-member 86 | self.test_points, 87 | os.path.join(self.output_gdb, "Junk"), 88 | self.local_nd, 89 | 30, 90 | 4, 91 | self.local_tm_time, 92 | "5000 Meters", 93 | ["Streets"], 94 | [["Streets", "ObjectID <> 1"], ["BadSourceName", "ObjectID <> 2"]] # Invalid source name 95 | ) 96 | actual_messages = str(ex.exception) 97 | # Check for expected GP message 98 | self.assertIn("30254", actual_messages) 99 | 100 | def test_error_duplicate_query_sources(self): 101 | """Test for correct error when a network source is specified more than once in the search query.""" 102 | with self.assertRaises(arcpy.ExecuteError) as ex: 103 | arcpy.LargeNetworkAnalysisTools.ParallelCalculateLocations( # pylint: disable=no-member 104 | self.test_points, 105 | os.path.join(self.output_gdb, "Junk"), 106 | self.local_nd, 107 | 30, 108 | 4, 109 | self.local_tm_time, 110 | "5000 Meters", 111 | ["Streets"], 112 | [["Streets", "ObjectID <> 1"], ["Streets", "ObjectID <> 2"]] # Duplicate query 113 | ) 114 | actual_messages = str(ex.exception) 115 | # Check for expected GP message 116 | self.assertIn("30255", actual_messages) 117 | 118 | def test_error_invalid_query(self): 119 | """Test for correct error when an invalid search query is specified.""" 120 | with self.assertRaises(arcpy.ExecuteError) as ex: 121 | arcpy.LargeNetworkAnalysisTools.ParallelCalculateLocations( # pylint: disable=no-member 122 | self.test_points, 123 | os.path.join(self.output_gdb, "Junk"), 124 | self.local_nd, 125 | 30, 126 | 4, 127 | self.local_tm_time, 128 | "5000 Meters", 129 | ["Streets"], 130 | [["Streets", "NAME = 1"]] # Bad query syntax 131 | ) 132 | actual_messages = str(ex.exception) 133 | # Check for expected validation message 134 | self.assertIn("An invalid SQL statement was used", actual_messages) 135 | 136 | 137 | if __name__ == '__main__': 138 | unittest.main() 139 | -------------------------------------------------------------------------------- /unittests/test_SolveLargeAnalysisWithKnownPairs_tool.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the SolveLargeAnalysisWithKnownPairs script tool. 2 | 3 | The test cases focus on making sure the tool parameters work correctly. 4 | 5 | Copyright 2023 Esri 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | # pylint: disable=import-error, invalid-name 17 | 18 | import sys 19 | import os 20 | import datetime 21 | import unittest 22 | from copy import deepcopy 23 | import arcpy 24 | import portal_credentials 25 | import input_data_helper 26 | 27 | CWD = os.path.dirname(os.path.abspath(__file__)) 28 | sys.path.append(os.path.dirname(CWD)) 29 | import helpers # noqa: E402, pylint: disable=wrong-import-position 30 | 31 | 32 | class TestSolveLargeAnalysisWithKnownPairsTool(unittest.TestCase): 33 | """Test cases for the SolveLargeAnalysisWithKnownPairs script tool.""" 34 | 35 | @classmethod 36 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 37 | """Set up shared inputs for tests.""" 38 | self.maxDiff = None 39 | 40 | tbx_path = os.path.join(os.path.dirname(CWD), "LargeNetworkAnalysisTools.pyt") 41 | arcpy.ImportToolbox(tbx_path) 42 | 43 | self.input_data_folder = os.path.join(CWD, "TestInput") 44 | sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 45 | self.origins = input_data_helper.get_tract_centroids_with_store_id_fc(sf_gdb) 46 | self.destinations = os.path.join(sf_gdb, "Analysis", "Stores") 47 | self.od_pairs_table = input_data_helper.get_od_pairs_fgdb_table(sf_gdb) 48 | self.local_nd = os.path.join(sf_gdb, "Transportation", "Streets_ND") 49 | tms = arcpy.nax.GetTravelModes(self.local_nd) 50 | self.local_tm_name = "Driving Time" 51 | self.local_tm_time = tms[self.local_tm_name] 52 | self.portal_nd = portal_credentials.PORTAL_URL # Must be arcgis.com for test to work 53 | self.portal_tm = portal_credentials.PORTAL_TRAVEL_MODE 54 | 55 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 56 | 57 | # Create a unique output directory and gdb for this test 58 | self.scratch_folder = os.path.join( 59 | CWD, "TestOutput", 60 | "Output_SolveLargeAnalysisWithKnownPairs_Tool_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 61 | os.makedirs(self.scratch_folder) 62 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 63 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 64 | 65 | # Copy some data to the output gdb to serve as barriers. Do not use tutorial data directly as input because 66 | # the tests will write network location fields to it, and we don't want to modify the user's original data. 67 | self.barriers = os.path.join(self.output_gdb, "Barriers") 68 | arcpy.management.Copy(os.path.join(sf_gdb, "Analysis", "CentralDepots"), self.barriers) 69 | 70 | def test_run_tool_one_to_one(self): 71 | """Test that the tool runs with all inputs for the one-to-one pair type.""" 72 | # Run tool 73 | out_routes = os.path.join(self.output_gdb, "OutRoutesLocal_OneToOne") 74 | arcpy.LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs( # pylint: disable=no-member 75 | self.origins, 76 | "ObjectID", 77 | self.destinations, 78 | "NAME", 79 | helpers.PAIR_TYPES[0], 80 | "StoreID", 81 | None, 82 | None, 83 | None, 84 | self.local_nd, 85 | self.local_tm_time, 86 | "Minutes", 87 | "Miles", 88 | 50, # chunk size 89 | 4, # max processes 90 | out_routes, 91 | datetime.datetime(2022, 3, 29, 16, 45, 0), # time of day 92 | self.barriers, # barriers 93 | True, # precalculate network locations 94 | True, # Sort origins 95 | False # Reverse direction of travel 96 | ) 97 | # Check results 98 | self.assertTrue(arcpy.Exists(out_routes)) 99 | 100 | def test_run_tool_many_to_many(self): 101 | """Test that the tool runs with all inputs for the many-to-many pair type.""" 102 | # Run tool 103 | out_routes = os.path.join(self.output_gdb, "OutRoutesLocal_ManyToMany") 104 | arcpy.LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs( # pylint: disable=no-member 105 | self.origins, 106 | "ID", 107 | self.destinations, 108 | "NAME", 109 | helpers.PAIR_TYPES[1], 110 | None, 111 | self.od_pairs_table, 112 | "OriginID", 113 | "DestinationID", 114 | self.local_nd, 115 | self.local_tm_time, 116 | "Minutes", 117 | "Miles", 118 | 20, # chunk size 119 | 4, # max processes 120 | out_routes, 121 | datetime.datetime(2022, 3, 29, 16, 45, 0), # time of day 122 | None, # barriers 123 | True, # precalculate network locations 124 | False, # Sort origins 125 | False # Reverse direction of travel 126 | ) 127 | # Check results 128 | self.assertTrue(arcpy.Exists(out_routes)) 129 | 130 | def test_run_tool_service(self): 131 | """Test that the tool runs with a service as a network data source. Use reverse order.""" 132 | out_routes = os.path.join(self.output_gdb, "OutRoutesService") 133 | arcpy.LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs( # pylint: disable=no-member 134 | self.origins, 135 | "ID", 136 | self.destinations, 137 | "NAME", 138 | helpers.PAIR_TYPES[0], 139 | "StoreID", 140 | None, 141 | None, 142 | None, 143 | self.portal_nd, 144 | self.portal_tm, 145 | "Minutes", 146 | "Miles", 147 | 50, # chunk size 148 | 4, # max processes 149 | out_routes, 150 | None, # time of day 151 | None, # barriers 152 | False, # precalculate network locations 153 | False, # Sort origins 154 | True # Reverse direction of travel 155 | ) 156 | # Check results 157 | self.assertTrue(arcpy.Exists(out_routes)) 158 | 159 | def test_error_max_processes(self): 160 | """Test for correct errors max processes parameter. 161 | 162 | This test primarily tests the shared cap_max_processes() function, so this is sufficient for testing all tools. 163 | """ 164 | inputs = { 165 | "Origins": self.origins, 166 | "Origin_Unique_ID_Field": "ID", 167 | "Destinations": self.destinations, 168 | "Destination_Unique_ID_Field": "NAME", 169 | "OD_Pair_Type": helpers.PAIR_TYPES[0], 170 | "Assigned_Destination_Field": "StoreID", 171 | "Network_Data_Source": self.local_nd, 172 | "Travel_Mode": self.local_tm_name, 173 | "Output_Routes": os.path.join(self.output_gdb, "Junk") 174 | } 175 | 176 | for bad_max_processes in [-2, 0]: 177 | with self.subTest(Max_Processes=bad_max_processes): 178 | with self.assertRaises(arcpy.ExecuteError) as ex: 179 | bad_inputs = deepcopy(inputs) 180 | bad_inputs["Max_Processes"] = bad_max_processes 181 | arcpy.LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs( # pylint: disable=no-member 182 | **bad_inputs 183 | ) 184 | expected_message = "The maximum number of parallel processes must be positive." 185 | actual_messages = str(ex.exception).strip().split("\n") 186 | self.assertIn(expected_message, actual_messages) 187 | 188 | bad_max_processes = 5000 189 | with self.subTest(Max_Processes=bad_max_processes): 190 | with self.assertRaises(arcpy.ExecuteError) as ex: 191 | bad_inputs = deepcopy(inputs) 192 | bad_inputs["Max_Processes"] = bad_max_processes 193 | arcpy.LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs( # pylint: disable=no-member 194 | **bad_inputs 195 | ) 196 | expected_message = ( 197 | f"The maximum number of parallel processes cannot exceed {helpers.MAX_ALLOWED_MAX_PROCESSES:} due " 198 | "to limitations imposed by Python's concurrent.futures module." 199 | ) 200 | actual_messages = str(ex.exception).strip().split("\n") 201 | self.assertIn(expected_message, actual_messages) 202 | 203 | bad_max_processes = 5 204 | with self.subTest(Max_Processes=bad_max_processes, Network_Data_Source=self.portal_nd): 205 | with self.assertRaises(arcpy.ExecuteError) as ex: 206 | bad_inputs = deepcopy(inputs) 207 | bad_inputs["Max_Processes"] = bad_max_processes 208 | bad_inputs["Network_Data_Source"] = self.portal_nd 209 | bad_inputs["Travel_Mode"] = self.portal_tm 210 | arcpy.LargeNetworkAnalysisTools.SolveLargeAnalysisWithKnownPairs( # pylint: disable=no-member 211 | **bad_inputs 212 | ) 213 | expected_message = ( 214 | f"The maximum number of parallel processes cannot exceed {helpers.MAX_AGOL_PROCESSES} when the " 215 | "ArcGIS Online service is used as the network data source." 216 | ) 217 | actual_messages = str(ex.exception).strip().split("\n") 218 | self.assertIn(expected_message, actual_messages) 219 | 220 | 221 | if __name__ == '__main__': 222 | unittest.main() 223 | -------------------------------------------------------------------------------- /unittests/test_SolveLargeODCostMatrix_tool.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the SolveLargeODCostMatrix script tool. The test cases focus 2 | on making sure the tool parameters work correctly. 3 | 4 | Copyright 2024 Esri 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | """ 15 | # pylint: disable=import-error, invalid-name 16 | 17 | import sys 18 | import os 19 | import datetime 20 | from glob import glob 21 | import unittest 22 | import arcpy 23 | import portal_credentials 24 | import input_data_helper 25 | 26 | CWD = os.path.dirname(os.path.abspath(__file__)) 27 | sys.path.append(os.path.dirname(CWD)) 28 | import helpers # noqa: E402, pylint: disable=wrong-import-position 29 | 30 | 31 | class TestSolveLargeODCostMatrixTool(unittest.TestCase): 32 | """Test cases for the SolveLargeODCostMatrix script tool.""" 33 | 34 | @classmethod 35 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 36 | self.maxDiff = None 37 | 38 | tbx_path = os.path.join(os.path.dirname(CWD), "LargeNetworkAnalysisTools.pyt") 39 | arcpy.ImportToolbox(tbx_path) 40 | 41 | self.input_data_folder = os.path.join(CWD, "TestInput") 42 | self.sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 43 | self.origins = os.path.join(self.sf_gdb, "Analysis", "TractCentroids") 44 | self.destinations = os.path.join(self.sf_gdb, "Analysis", "Hospitals") 45 | self.local_nd = os.path.join(self.sf_gdb, "Transportation", "Streets_ND") 46 | tms = arcpy.nax.GetTravelModes(self.local_nd) 47 | self.local_tm_time = tms["Driving Time"] 48 | self.local_tm_dist = tms["Driving Distance"] 49 | self.portal_nd = portal_credentials.PORTAL_URL # Must be arcgis.com for test to work 50 | self.portal_tm = portal_credentials.PORTAL_TRAVEL_MODE 51 | 52 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 53 | 54 | # Create a unique output directory and gdb for this test 55 | self.scratch_folder = os.path.join( 56 | CWD, "TestOutput", 57 | "Output_SolveLargeODCostMatrix_Tool_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 58 | os.makedirs(self.scratch_folder) 59 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 60 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 61 | 62 | def test_run_tool_time_units_feature_class(self): 63 | """Test that the tool runs with a time-based travel mode. Write output to a feature class.""" 64 | # Run tool 65 | out_od_lines = os.path.join(self.output_gdb, "Time_ODLines") 66 | out_origins = os.path.join(self.output_gdb, "Time_Origins") 67 | out_destinations = os.path.join(self.output_gdb, "Time_Destinations") 68 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 69 | self.origins, 70 | self.destinations, 71 | self.local_nd, 72 | self.local_tm_time, 73 | "Minutes", 74 | "Miles", 75 | 50, # chunk size 76 | 4, # max processes 77 | out_origins, 78 | out_destinations, 79 | "Feature class", 80 | out_od_lines, 81 | None, 82 | 15, # cutoff 83 | 1, # number of destinations 84 | datetime.datetime(2022, 3, 29, 16, 45, 0), # time of day 85 | None, # barriers 86 | True, # precalculate network locations 87 | True # Spatially sort inputs 88 | ) 89 | # Check results 90 | self.assertTrue(arcpy.Exists(out_od_lines)) 91 | self.assertTrue(arcpy.Exists(out_origins)) 92 | self.assertTrue(arcpy.Exists(out_destinations)) 93 | 94 | def test_run_tool_distance_units_csv_barriers(self): 95 | """Test that the tool runs with a distance-based travel mode. Write output to CSVs. Also use barriers.""" 96 | # Copy some data to the output gdb to serve as barriers. Do not use tutorial data directly as input because 97 | # the tests will write network location fields to it, and we don't want to modify the user's original data. 98 | barriers = os.path.join(self.output_gdb, "Barriers") 99 | arcpy.management.Copy(os.path.join(self.sf_gdb, "Analysis", "CentralDepots"), barriers) 100 | 101 | # Run tool 102 | out_origins = os.path.join(self.output_gdb, "Dist_Origins") 103 | out_destinations = os.path.join(self.output_gdb, "Dist_Destinations") 104 | out_folder = os.path.join(self.scratch_folder, "DistUnits_CSVs") 105 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 106 | self.origins, 107 | self.destinations, 108 | self.local_nd, 109 | self.local_tm_dist, 110 | "Minutes", 111 | "Miles", 112 | 50, # chunk size 113 | 4, # max processes 114 | out_origins, 115 | out_destinations, 116 | "CSV files", 117 | None, 118 | out_folder, 119 | 5, # cutoff 120 | 2, # number of destinations 121 | None, # time of day 122 | barriers, # barriers 123 | True, # precalculate network locations 124 | True # Spatially sort inputs 125 | ) 126 | # Check results 127 | self.assertTrue(arcpy.Exists(out_origins)) 128 | self.assertTrue(arcpy.Exists(out_destinations)) 129 | csv_files = glob(os.path.join(out_folder, "*.csv")) 130 | self.assertGreater(len(csv_files), 0) 131 | 132 | @unittest.skipIf( 133 | helpers.arcgis_version < "2.9", "Arrow table output is not available in versions of Pro prior to 2.9.") 134 | def test_run_tool_time_units_arrow(self): 135 | """Test the tool with Apache Arrow outputs.""" 136 | # Run tool 137 | out_origins = os.path.join(self.output_gdb, "Arrow_Origins") 138 | out_destinations = os.path.join(self.output_gdb, "Arrow_Destinations") 139 | out_folder = os.path.join(self.scratch_folder, "ArrowOutputs") 140 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 141 | self.origins, 142 | self.destinations, 143 | self.local_nd, 144 | self.local_tm_dist, 145 | "Minutes", 146 | "Miles", 147 | 50, # chunk size 148 | 4, # max processes 149 | out_origins, 150 | out_destinations, 151 | "Apache Arrow files", 152 | None, 153 | out_folder, 154 | 5, # cutoff 155 | 2, # number of destinations 156 | None, # time of day 157 | None, # barriers 158 | True, # precalculate network locations 159 | True # Spatially sort inputs 160 | ) 161 | # Check results 162 | self.assertTrue(arcpy.Exists(out_origins)) 163 | self.assertTrue(arcpy.Exists(out_destinations)) 164 | csv_files = glob(os.path.join(out_folder, "*.arrow")) 165 | self.assertGreater(len(csv_files), 0) 166 | 167 | def test_run_tool_service(self): 168 | """Test that the tool runs with a service as a network data source.""" 169 | out_od_lines = os.path.join(self.output_gdb, "Service_ODLines") 170 | out_origins = os.path.join(self.output_gdb, "Service_Origins") 171 | out_destinations = os.path.join(self.output_gdb, "Service_Destinations") 172 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 173 | self.origins, 174 | self.destinations, 175 | self.portal_nd, 176 | self.portal_tm, 177 | "Minutes", 178 | "Miles", 179 | 50, # chunk size 180 | 4, # max processes 181 | out_origins, 182 | out_destinations, 183 | "Feature class", 184 | out_od_lines, 185 | None, 186 | 15, # cutoff 187 | 1, # number of destinations 188 | None, # time of day 189 | None, # barriers 190 | False, # precalculate network locations 191 | True # Spatially sort inputs 192 | ) 193 | # Check results 194 | self.assertTrue(arcpy.Exists(out_od_lines)) 195 | self.assertTrue(arcpy.Exists(out_origins)) 196 | self.assertTrue(arcpy.Exists(out_destinations)) 197 | 198 | def test_run_tool_per_origin_cutoff(self): 199 | """Test that the tool correctly uses the Cutoff field in the input origins layer.""" 200 | # Run tool 201 | origins = input_data_helper.get_tract_centroids_with_cutoff(self.sf_gdb) 202 | out_od_lines = os.path.join(self.output_gdb, "PerOriginCutoff_ODLines") 203 | out_origins = os.path.join(self.output_gdb, "PerOriginCutoff_Origins") 204 | out_destinations = os.path.join(self.output_gdb, "PerOriginCutoff_Destinations") 205 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 206 | origins, 207 | os.path.join(self.sf_gdb, "Analysis", "FireStations"), 208 | self.local_nd, 209 | self.local_tm_time, 210 | "Minutes", 211 | "Miles", 212 | 50, # chunk size 213 | 4, # max processes 214 | out_origins, 215 | out_destinations, 216 | "Feature class", 217 | out_od_lines, 218 | None, 219 | 1, # cutoff - tiny cutoff that is overridden for one origin 220 | None, # number of destinations 221 | None, # time of day 222 | None, # barriers 223 | True, # precalculate network locations 224 | True # Spatially sort inputs 225 | ) 226 | # Check results 227 | self.assertTrue(arcpy.Exists(out_od_lines)) 228 | self.assertTrue(arcpy.Exists(out_origins)) 229 | self.assertTrue(arcpy.Exists(out_destinations)) 230 | self.assertEqual(63, int(arcpy.management.GetCount(out_od_lines).getOutput(0)), "Incorrect number of OD lines") 231 | num_dests = 0 232 | for row in arcpy.da.SearchCursor(out_od_lines, ["OriginName", "Total_Time"]): 233 | if row[0] == "060816029.00": 234 | num_dests += 1 235 | self.assertLessEqual(row[1], 15, "Travel time is out of bounds for origin with its own cutoff") 236 | else: 237 | self.assertLessEqual(row[1], 1, "Travel time is out of bounds for origin with with default cutoff") 238 | self.assertEqual(13, num_dests, "Incorrect number of destinations found for origin with its own cutoff.") 239 | 240 | def test_run_tool_per_origin_dest_count(self): 241 | """Test that the tool correctly uses the TargetDestinationCount field in the input origins layer.""" 242 | # Run tool 243 | origins = input_data_helper.get_stores_with_dest_count(self.sf_gdb) 244 | out_od_lines = os.path.join(self.output_gdb, "PerOriginDestCount_ODLines") 245 | out_origins = os.path.join(self.output_gdb, "PerOriginDestCount_Origins") 246 | out_destinations = os.path.join(self.output_gdb, "PerOriginDestCount_Destinations") 247 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 248 | origins, 249 | self.destinations, 250 | self.local_nd, 251 | self.local_tm_time, 252 | "Minutes", 253 | "Miles", 254 | 10, # chunk size 255 | 4, # max processes 256 | out_origins, 257 | out_destinations, 258 | "Feature class", 259 | out_od_lines, 260 | None, 261 | None, # cutoff 262 | 1, # number of destinations - overridden for one origin 263 | None, # time of day 264 | None, # barriers 265 | True, # precalculate network locations 266 | True # Spatially sort inputs 267 | ) 268 | # Check results 269 | self.assertTrue(arcpy.Exists(out_od_lines)) 270 | self.assertTrue(arcpy.Exists(out_origins)) 271 | self.assertTrue(arcpy.Exists(out_destinations)) 272 | self.assertEqual(28, int(arcpy.management.GetCount(out_od_lines).getOutput(0)), "Incorrect number of OD lines") 273 | # Check Store_1, which should have 3 destinations 274 | num_rows = 0 275 | prev_time = 0 276 | for row in arcpy.da.SearchCursor(out_od_lines, [ "Total_Time", "DestinationRank"], "OriginName = 'Store_1'"): 277 | num_rows += 1 278 | self.assertEqual(num_rows, row[1], "Incorrect DestinationRank value for Store_1") 279 | self.assertGreater(row[0], prev_time, "Total_Time value for Store_1 isn't increasing") 280 | prev_time = row[0] 281 | self.assertEqual(3, num_rows, "Incorrect number of destinations found for Store_1") 282 | # Check Store_2, which should have 2 destinations 283 | num_rows = 0 284 | prev_time = 0 285 | for row in arcpy.da.SearchCursor(out_od_lines, [ "Total_Time", "DestinationRank"], "OriginName = 'Store_2'"): 286 | num_rows += 1 287 | self.assertEqual(num_rows, row[1], "Incorrect DestinationRank value for Store_2") 288 | self.assertGreater(row[0], prev_time, "Total_Time value for Store_2 isn't increasing") 289 | prev_time = row[0] 290 | self.assertEqual(2, num_rows, "Incorrect number of destinations found for Store_2") 291 | 292 | def test_error_required_output_od_lines(self): 293 | """Test for correct error when output format is Feature class and output OD Lines not specified.""" 294 | with self.assertRaises(arcpy.ExecuteError) as ex: 295 | # Run tool 296 | out_origins = os.path.join(self.output_gdb, "Err_Origins") 297 | out_destinations = os.path.join(self.output_gdb, "Err_Destinations") 298 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 299 | self.origins, 300 | self.destinations, 301 | self.local_nd, 302 | self.local_tm_time, 303 | "Minutes", 304 | "Miles", 305 | 50, # chunk size 306 | 4, # max processes 307 | out_origins, 308 | out_destinations, 309 | "Feature class", 310 | None, 311 | "Junk", 312 | 15, # cutoff 313 | 1, # number of destinations 314 | None, # time of day 315 | None, # barriers 316 | True, # precalculate network locations 317 | True # Spatially sort inputs 318 | ) 319 | expected_messages = [ 320 | "Failed to execute. Parameters are not valid.", 321 | "ERROR 000735: Output OD Lines Feature Class: Value is required", 322 | "Failed to execute (SolveLargeODCostMatrix)." 323 | ] 324 | actual_messages = str(ex.exception).strip().split("\n") 325 | self.assertEqual(expected_messages, actual_messages) 326 | 327 | def test_error_required_output_folder(self): 328 | """Test for correct error when output format is CSV files and output folder not specified.""" 329 | with self.assertRaises(arcpy.ExecuteError) as ex: 330 | # Run tool 331 | out_origins = os.path.join(self.output_gdb, "Err_Origins") 332 | out_destinations = os.path.join(self.output_gdb, "Err_Destinations") 333 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 334 | self.origins, 335 | self.destinations, 336 | self.local_nd, 337 | self.local_tm_time, 338 | "Minutes", 339 | "Miles", 340 | 50, # chunk size 341 | 4, # max processes 342 | out_origins, 343 | out_destinations, 344 | "CSV files", 345 | "Junk", 346 | None, 347 | 15, # cutoff 348 | 1, # number of destinations 349 | None, # time of day 350 | None, # barriers 351 | True, # precalculate network locations 352 | True # Spatially sort inputs 353 | ) 354 | expected_messages = [ 355 | "Failed to execute. Parameters are not valid.", 356 | "ERROR 000735: Output Folder: Value is required", 357 | "Failed to execute (SolveLargeODCostMatrix)." 358 | ] 359 | actual_messages = str(ex.exception).strip().split("\n") 360 | self.assertEqual(expected_messages, actual_messages) 361 | 362 | def test_error_output_folder_exists(self): 363 | """Test for correct error when output folder already exists.""" 364 | out_folder = os.path.join(self.scratch_folder, "ExistingOutFolder") 365 | os.makedirs(out_folder) 366 | with self.assertRaises(arcpy.ExecuteError) as ex: 367 | # Run tool 368 | out_origins = os.path.join(self.output_gdb, "Err_Origins") 369 | out_destinations = os.path.join(self.output_gdb, "Err_Destinations") 370 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 371 | self.origins, 372 | self.destinations, 373 | self.local_nd, 374 | self.local_tm_time, 375 | "Minutes", 376 | "Miles", 377 | 50, # chunk size 378 | 4, # max processes 379 | out_origins, 380 | out_destinations, 381 | "CSV files", 382 | "Junk", 383 | out_folder, 384 | 15, # cutoff 385 | 1, # number of destinations 386 | None, # time of day 387 | None, # barriers 388 | True, # precalculate network locations 389 | True # Spatially sort inputs 390 | ) 391 | expected_messages = [ 392 | "Failed to execute. Parameters are not valid.", 393 | f"ERROR 000012: {out_folder} already exists", 394 | "Failed to execute (SolveLargeODCostMatrix)." 395 | ] 396 | actual_messages = str(ex.exception).strip().split("\n") 397 | self.assertEqual(expected_messages, actual_messages) 398 | 399 | @unittest.skipIf( 400 | helpers.arcgis_version < "2.9", "Arrow table output is not available in versions of Pro prior to 2.9.") 401 | def test_error_arrow_service(self): 402 | """Test for correct error when the network data source is a service and requesting Arrow output.""" 403 | with self.assertRaises(arcpy.ExecuteError) as ex: 404 | # Run tool 405 | out_od_folder = os.path.join(self.scratch_folder, "Err") 406 | out_origins = os.path.join(self.output_gdb, "Err_Origins") 407 | out_destinations = os.path.join(self.output_gdb, "Err_Destinations") 408 | arcpy.LargeNetworkAnalysisTools.SolveLargeODCostMatrix( # pylint: disable=no-member 409 | self.origins, 410 | self.destinations, 411 | self.portal_nd, 412 | self.portal_tm, 413 | "Minutes", 414 | "Miles", 415 | 50, # chunk size 416 | 4, # max processes 417 | out_origins, 418 | out_destinations, 419 | "Apache Arrow files", 420 | None, 421 | out_od_folder, 422 | 15, # cutoff 423 | 1, # number of destinations 424 | None, # time of day 425 | None, # barriers 426 | True, # precalculate network locations 427 | True # Spatially sort inputs 428 | ) 429 | expected_messages = [ 430 | "Failed to execute. Parameters are not valid.", 431 | "Apache Arrow files output format is not available when a service is used as the network data source.", 432 | "Failed to execute (SolveLargeODCostMatrix)." 433 | ] 434 | actual_messages = str(ex.exception).strip().split("\n") 435 | self.assertEqual(expected_messages, actual_messages) 436 | 437 | 438 | if __name__ == '__main__': 439 | unittest.main() 440 | -------------------------------------------------------------------------------- /unittests/test_helpers.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the helpers.py module. 2 | 3 | Copyright 2023 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | import sys 15 | import os 16 | import datetime 17 | import logging 18 | import unittest 19 | import arcpy 20 | import portal_credentials # Contains log-in for an ArcGIS Online account to use as a test portal 21 | 22 | CWD = os.path.dirname(os.path.abspath(__file__)) 23 | sys.path.append(os.path.dirname(CWD)) 24 | import helpers # noqa: E402, pylint: disable=wrong-import-position 25 | from od_config import OD_PROPS # noqa: E402, pylint: disable=wrong-import-position 26 | from rt_config import RT_PROPS # noqa: E402, pylint: disable=wrong-import-position 27 | 28 | 29 | class TestHelpers(unittest.TestCase): 30 | """Test cases for the helpers module.""" 31 | 32 | @classmethod 33 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 34 | """Set up shared test properties.""" 35 | self.maxDiff = None 36 | 37 | self.input_data_folder = os.path.join(CWD, "TestInput") 38 | self.sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 39 | self.local_nd = os.path.join(self.sf_gdb, "Transportation", "Streets_ND") 40 | self.portal_nd = portal_credentials.PORTAL_URL 41 | 42 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 43 | 44 | self.scratch_folder = os.path.join( 45 | CWD, "TestOutput", "Output_Helpers_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 46 | os.makedirs(self.scratch_folder) 47 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 48 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 49 | 50 | def test_is_nds_service(self): 51 | """Test the is_nds_service function.""" 52 | self.assertTrue(helpers.is_nds_service(self.portal_nd)) 53 | self.assertFalse(helpers.is_nds_service(self.local_nd)) 54 | 55 | def test_get_tool_limits_and_is_agol(self): 56 | """Test the _get_tool_limits_and_is_agol function for a portal network data source.""" 57 | services = [ 58 | ("asyncODCostMatrix", "GenerateOriginDestinationCostMatrix"), 59 | ("asyncRoute", "FindRoutes") 60 | ] 61 | for service in services: 62 | with self.subTest(service=service): 63 | service_limits, is_agol = helpers.get_tool_limits_and_is_agol( 64 | self.portal_nd, service[0], service[1]) 65 | self.assertIsInstance(service_limits, dict) 66 | self.assertIsInstance(is_agol, bool) 67 | if service[0] == "asyncODCostMatrix": 68 | self.assertIn("maximumDestinations", service_limits) 69 | self.assertIn("maximumOrigins", service_limits) 70 | elif service[0] == "asyncRoute": 71 | self.assertIn("maximumStops", service_limits) 72 | if "arcgis.com" in self.portal_nd: 73 | # Note: If testing with some other portal, this test would need to be updated. 74 | self.assertTrue(is_agol) 75 | 76 | def test_update_agol_max_processes(self): 77 | """Test the update_agol_max_processes function.""" 78 | self.assertEqual(helpers.MAX_AGOL_PROCESSES, helpers.update_agol_max_processes(5000)) 79 | 80 | def test_convert_time_units_str_to_enum(self): 81 | """Test the convert_time_units_str_to_enum function.""" 82 | # Test all valid units 83 | valid_units = helpers.TIME_UNITS 84 | for unit in valid_units: 85 | enum_unit = helpers.convert_time_units_str_to_enum(unit) 86 | self.assertIsInstance(enum_unit, arcpy.nax.TimeUnits) 87 | self.assertEqual(unit.lower(), enum_unit.name.lower()) 88 | # Test for correct error with invalid units 89 | bad_unit = "BadUnit" 90 | with self.assertRaises(ValueError) as ex: 91 | helpers.convert_time_units_str_to_enum(bad_unit) 92 | self.assertEqual(f"Invalid time units: {bad_unit}", str(ex.exception)) 93 | 94 | def test_convert_distance_units_str_to_enum(self): 95 | """Test the convert_distance_units_str_to_enum function.""" 96 | # Test all valid units 97 | valid_units = helpers.DISTANCE_UNITS 98 | for unit in valid_units: 99 | enum_unit = helpers.convert_distance_units_str_to_enum(unit) 100 | self.assertIsInstance(enum_unit, arcpy.nax.DistanceUnits) 101 | self.assertEqual(unit.lower(), enum_unit.name.lower()) 102 | # Test for correct error with invalid units 103 | bad_unit = "BadUnit" 104 | with self.assertRaises(ValueError) as ex: 105 | helpers.convert_distance_units_str_to_enum(bad_unit) 106 | self.assertEqual(f"Invalid distance units: {bad_unit}", str(ex.exception)) 107 | 108 | def test_convert_output_format_str_to_enum(self): 109 | """Test the convert_output_format_str_to_enum function.""" 110 | # Test all valid formats 111 | valid_formats = helpers.OUTPUT_FORMATS 112 | for fm in valid_formats: 113 | enum_format = helpers.convert_output_format_str_to_enum(fm) 114 | self.assertIsInstance(enum_format, helpers.OutputFormat) 115 | # Test for correct error with an invalid format type 116 | bad_format = "BadFormat" 117 | with self.assertRaises(ValueError) as ex: 118 | helpers.convert_output_format_str_to_enum(bad_format) 119 | self.assertEqual(f"Invalid output format: {bad_format}", str(ex.exception)) 120 | 121 | def test_convert_pair_type_str_to_enum(self): 122 | """Test the convert_pair_type_str_to_enum function.""" 123 | # Test all valid pair types 124 | valid_pair_types = helpers.PAIR_TYPES 125 | for fm in valid_pair_types: 126 | enum_format = helpers.convert_pair_type_str_to_enum(fm) 127 | self.assertIsInstance(enum_format, helpers.PreassignedODPairType) 128 | # Test for correct error with an invalid format type 129 | bad_format = "BadFormat" 130 | with self.assertRaises(ValueError) as ex: 131 | helpers.convert_pair_type_str_to_enum(bad_format) 132 | self.assertEqual(f"Invalid OD pair assignment type: {bad_format}", str(ex.exception)) 133 | 134 | def test_validate_input_feature_class(self): 135 | """Test the validate_input_feature_class function.""" 136 | # Test when the input feature class does note exist. 137 | input_fc = os.path.join(self.sf_gdb, "DoesNotExist") 138 | with self.subTest(feature_class=input_fc): 139 | with self.assertRaises(ValueError) as ex: 140 | helpers.validate_input_feature_class(input_fc) 141 | self.assertEqual(f"Input dataset {input_fc} does not exist.", str(ex.exception)) 142 | 143 | # Test when the input feature class is empty 144 | input_fc = os.path.join(self.output_gdb, "EmptyFC") 145 | with self.subTest(feature_class=input_fc): 146 | arcpy.management.CreateFeatureclass(self.output_gdb, os.path.basename(input_fc)) 147 | with self.assertRaises(ValueError) as ex: 148 | helpers.validate_input_feature_class(input_fc) 149 | self.assertEqual(f"Input dataset {input_fc} has no rows.", str(ex.exception)) 150 | 151 | def test_are_input_layers_the_same(self): 152 | """Test the are_input_layers_the_same function.""" 153 | fc1 = os.path.join(self.sf_gdb, "Analysis", "TractCentroids") 154 | fc2 = os.path.join(self.sf_gdb, "Analysis", "Hospitals") 155 | lyr1_name = "Layer1" 156 | lyr2_name = "Layer2" 157 | lyr1_obj = arcpy.management.MakeFeatureLayer(fc1, lyr1_name) 158 | lyr1_obj_again = arcpy.management.MakeFeatureLayer(fc1, "Layer1 again") 159 | lyr2_obj = arcpy.management.MakeFeatureLayer(fc2, lyr2_name) 160 | lyr1_file = os.path.join(self.scratch_folder, "lyr1.lyrx") 161 | lyr2_file = os.path.join(self.scratch_folder, "lyr2.lyrx") 162 | arcpy.management.SaveToLayerFile(lyr1_obj, lyr1_file) 163 | arcpy.management.SaveToLayerFile(lyr2_obj, lyr2_file) 164 | fset_1 = arcpy.FeatureSet(fc1) 165 | fset_2 = arcpy.FeatureSet(fc2) 166 | 167 | # Feature class catalog path inputs 168 | self.assertFalse(helpers.are_input_layers_the_same(fc1, fc2)) 169 | self.assertTrue(helpers.are_input_layers_the_same(fc1, fc1)) 170 | # Layer inputs 171 | self.assertFalse(helpers.are_input_layers_the_same(lyr1_name, lyr2_name)) 172 | self.assertTrue(helpers.are_input_layers_the_same(lyr1_name, lyr1_name)) 173 | self.assertFalse(helpers.are_input_layers_the_same(lyr1_obj, lyr2_obj)) 174 | self.assertTrue(helpers.are_input_layers_the_same(lyr1_obj, lyr1_obj)) 175 | self.assertFalse(helpers.are_input_layers_the_same(lyr1_obj, lyr1_obj_again)) 176 | self.assertFalse(helpers.are_input_layers_the_same(lyr1_file, lyr2_file)) 177 | self.assertTrue(helpers.are_input_layers_the_same(lyr1_file, lyr1_file)) 178 | # Feature set inputs 179 | self.assertFalse(helpers.are_input_layers_the_same(fset_1, fset_2)) 180 | self.assertTrue(helpers.are_input_layers_the_same(fset_1, fset_1)) 181 | 182 | def test_validate_network_data_source(self): 183 | """Test the validate_network_data_source function.""" 184 | # Check that it returns the catalog path of a network dataset layer 185 | nd_layer = arcpy.na.MakeNetworkDatasetLayer(self.local_nd).getOutput(0) 186 | with self.subTest(network_data_source=nd_layer): 187 | self.assertEqual(self.local_nd, helpers.validate_network_data_source(nd_layer)) 188 | # Check that it returns a portal URL with a trailing slash if it initially lacked one 189 | portal_url = self.portal_nd.strip(r"/") 190 | with self.subTest(network_data_source=portal_url): 191 | self.assertEqual(portal_url + r"/", helpers.validate_network_data_source(portal_url)) 192 | # Check for ValueError if the network dataset doesn't exist 193 | bad_network = os.path.join(self.sf_gdb, "Transportation", "DoesNotExist") 194 | with self.subTest(network_data_source=bad_network): 195 | with self.assertRaises(ValueError) as ex: 196 | helpers.validate_network_data_source(bad_network) 197 | self.assertEqual(str(ex.exception), f"Input network dataset {bad_network} does not exist.") 198 | 199 | def test_get_locatable_network_source_names(self): 200 | """Test the get_locatable_network_source_names funtion.""" 201 | self.assertEqual( 202 | ["Streets", "Streets_ND_Junctions"], 203 | helpers.get_locatable_network_source_names(self.local_nd) 204 | ) 205 | 206 | def test_get_default_locatable_network_source_names(self): 207 | """Test the get_default_locatable_network_source_names funtion.""" 208 | self.assertEqual( 209 | ["Streets"], 210 | helpers.get_default_locatable_network_source_names(self.local_nd) 211 | ) 212 | 213 | def test_get_locate_settings_from_config_file(self): 214 | """Test the get_locate_settings_from_config_file function.""" 215 | # Test searchTolerance and searchQuery without searchSources 216 | config_props = { 217 | "searchQuery": [["Streets", "ObjectID <> 1"], ["Streets_ND_Junctions", ""]], 218 | "searchTolerance": 1000, 219 | "searchToleranceUnits": arcpy.nax.DistanceUnits.Feet 220 | } 221 | search_tolerance, search_criteria, search_query = helpers.get_locate_settings_from_config_file( 222 | config_props, self.local_nd) 223 | self.assertEqual("1000 Feet", search_tolerance, "Incorrect search tolerance.") 224 | self.assertEqual( 225 | "", search_criteria, 226 | "Search criteria should be an empty string when searchSources is not used.") 227 | self.assertEqual("Streets 'ObjectID <> 1';Streets_ND_Junctions #", search_query, "Incorrect search query.") 228 | 229 | # Test searchSources 230 | config_props = { 231 | "searchSources": [["Streets", "ObjectID <> 1"]], 232 | "searchTolerance": 1000, 233 | } 234 | search_tolerance, search_criteria, search_query = helpers.get_locate_settings_from_config_file( 235 | config_props, self.local_nd) 236 | self.assertEqual( 237 | "", search_tolerance, 238 | "Search tolerance should be an empty string when both searchTolerance and searchToleranceUnits are not set." 239 | ) 240 | self.assertEqual( 241 | "Streets SHAPE;Streets_ND_Junctions NONE", search_criteria, 242 | "Incorrect search criteria.") 243 | self.assertEqual("Streets 'ObjectID <> 1'", search_query, "Incorrect search query.") 244 | 245 | def test_get_oid_ranges_for_input(self): 246 | """Test the get_oid_ranges_for_input function.""" 247 | ranges = helpers.get_oid_ranges_for_input(os.path.join(self.sf_gdb, "Analysis", "TractCentroids"), 50) 248 | self.assertEqual([[1, 50], [51, 100], [101, 150], [151, 200], [201, 208]], ranges) 249 | 250 | def test_parse_std_and_write_to_gp_ui(self): 251 | """Test the parse_std_and_write_to_gp_ui function.""" 252 | # There is nothing much to test here except that nothing terrible happens. 253 | msgs = [ 254 | f"CRITICAL{helpers.MSG_STR_SPLITTER}Critical message", 255 | f"ERROR{helpers.MSG_STR_SPLITTER}Error message", 256 | f"WARNING{helpers.MSG_STR_SPLITTER}Warning message", 257 | f"INFO{helpers.MSG_STR_SPLITTER}Info message", 258 | f"DEBUG{helpers.MSG_STR_SPLITTER}Debug message", 259 | "Poorly-formatted message 1", 260 | f"Poorly-formatted{helpers.MSG_STR_SPLITTER}message 2" 261 | ] 262 | for msg in msgs: 263 | with self.subTest(msg=msg): 264 | helpers.parse_std_and_write_to_gp_ui(msg) 265 | 266 | def test_run_gp_tool(self): 267 | """Test the run_gp_tool function.""" 268 | # Set up a logger to use with the function 269 | logger = logging.getLogger(__name__) # pylint:disable=invalid-name 270 | # Test for handled tool execute error (create fgdb in invalid folder) 271 | with self.assertRaises(arcpy.ExecuteError): 272 | helpers.run_gp_tool( 273 | logger, 274 | arcpy.management.CreateFileGDB, 275 | [self.scratch_folder + "DoesNotExist"], 276 | {"out_name": "outputs.gdb"} 277 | ) 278 | # Test for handled non-arcpy error when calling function 279 | with self.assertRaises(TypeError): 280 | helpers.run_gp_tool(logger, "BadTool", [self.scratch_folder]) 281 | # Valid call to tool with simple function 282 | helpers.run_gp_tool( 283 | logger, arcpy.management.CreateFileGDB, [self.scratch_folder], {"out_name": "testRunTool.gdb"}) 284 | 285 | 286 | if __name__ == '__main__': 287 | unittest.main() 288 | -------------------------------------------------------------------------------- /unittests/test_parallel_calculate_locations.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the parallel_calculate_locations.py module. 2 | 3 | Copyright 2023 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | # pylint: disable=import-error, protected-access, invalid-name 15 | 16 | import sys 17 | import os 18 | import datetime 19 | import subprocess 20 | import unittest 21 | import arcpy 22 | 23 | CWD = os.path.dirname(os.path.abspath(__file__)) 24 | sys.path.append(os.path.dirname(CWD)) 25 | import parallel_calculate_locations # noqa: E402, pylint: disable=wrong-import-position 26 | from helpers import configure_global_logger, teardown_logger 27 | 28 | 29 | class TestParallelCalculateLocations(unittest.TestCase): 30 | """Test cases for the parallel_calculate_locations module.""" 31 | 32 | @classmethod 33 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 34 | """Set up shared test properties.""" 35 | self.maxDiff = None 36 | 37 | self.input_data_folder = os.path.join(CWD, "TestInput") 38 | sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 39 | self.input_fc = os.path.join(sf_gdb, "Analysis", "Stores") 40 | self.local_nd = os.path.join(sf_gdb, "Transportation", "Streets_ND") 41 | self.local_tm_time = "Driving Time" 42 | 43 | # Create a unique output directory and gdb for this test 44 | self.output_folder = os.path.join( 45 | CWD, "TestOutput", "Output_ParallelCalcLocs_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 46 | os.makedirs(self.output_folder) 47 | self.output_gdb = os.path.join(self.output_folder, "outputs.gdb") 48 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 49 | 50 | def check_precalculated_locations(self, fc, check_has_values): 51 | """Check precalculated locations.""" 52 | loc_fields = {"SourceID", "SourceOID", "PosAlong", "SideOfEdge"} 53 | actual_fields = set([f.name for f in arcpy.ListFields(fc)]) 54 | self.assertTrue(loc_fields.issubset(actual_fields), "Network location fields not added") 55 | if check_has_values: 56 | for row in arcpy.da.SearchCursor(fc, list(loc_fields)): # pylint: disable=no-member 57 | for val in row: 58 | self.assertIsNotNone(val) 59 | 60 | def test_LocationCalculator_subset_inputs(self): 61 | """Test the _subset_inputs method of the LocationCalculator class.""" 62 | inputs = { 63 | "input_fc": self.input_fc, 64 | "network_data_source": self.local_nd, 65 | "travel_mode": self.local_tm_time, 66 | "scratch_folder": self.output_folder 67 | } 68 | location_calculator = parallel_calculate_locations.LocationCalculator(**inputs) 69 | location_calculator._subset_inputs([9, 14]) 70 | self.assertTrue(arcpy.Exists(location_calculator.out_fc), "Subset fc does not exist.") 71 | self.assertEqual( 72 | 6, int(arcpy.management.GetCount(location_calculator.out_fc).getOutput(0)), 73 | "Subset feature class has the wrong number of rows." 74 | ) 75 | 76 | def test_LocationCalculator_calculate_locations(self): 77 | """Test the calculate_locations method of the LocationCalculator class. 78 | 79 | Use all optional Calculate Locations tool settings. 80 | """ 81 | fc_to_precalculate = os.path.join(self.output_gdb, "PrecalcFC_LocationCalculator") 82 | arcpy.management.Copy(self.input_fc, fc_to_precalculate) 83 | inputs = { 84 | "input_fc": fc_to_precalculate, 85 | "network_data_source": self.local_nd, 86 | "travel_mode": self.local_tm_time, 87 | "scratch_folder": self.output_folder, 88 | "search_tolerance": "1000 Feet", 89 | "search_criteria": [["Streets", "SHAPE"], ["Streets_ND_Junctions", "SHAPE"]], 90 | "search_query": [["Streets", "ObjectID <> 1"], ["Streets_ND_Junctions", ""]] 91 | } 92 | location_calculator = parallel_calculate_locations.LocationCalculator(**inputs) 93 | oid_range = [9, 14] 94 | location_calculator.calculate_locations(oid_range) 95 | self.assertTrue(arcpy.Exists(location_calculator.out_fc), "Subset fc does not exist.") 96 | self.assertEqual( 97 | 6, int(arcpy.management.GetCount(location_calculator.out_fc).getOutput(0)), 98 | "Subset feature class has the wrong number of rows." 99 | ) 100 | self.check_precalculated_locations(location_calculator.out_fc, check_has_values=False) 101 | self.assertEqual( 102 | location_calculator.out_fc, location_calculator.job_result["outputFC"], 103 | "outputFC property of job_result was not set correctly." 104 | ) 105 | self.assertEqual( 106 | tuple(oid_range), location_calculator.job_result["oidRange"], 107 | "oidRange property of job_result was not set correctly." 108 | ) 109 | 110 | def test_ParallelLocationCalculator(self): 111 | """Test the ParallelLocationCalculator class.""" 112 | # The input feature class should not be overwritten by this tool, but copy it first just in case. 113 | fc_to_precalculate = os.path.join(self.output_gdb, "PrecalcFC_Parallel") 114 | arcpy.management.Copy(self.input_fc, fc_to_precalculate) 115 | out_fc = os.path.join(self.output_gdb, "PrecalcFC_Parallel_out") 116 | logger = configure_global_logger(parallel_calculate_locations.LOG_LEVEL) 117 | inputs = { 118 | "logger": logger, 119 | "input_features": fc_to_precalculate, 120 | "output_features": out_fc, 121 | "chunk_size": 6, 122 | "max_processes": 4, 123 | "network_data_source": self.local_nd, 124 | "travel_mode": self.local_tm_time, 125 | "search_tolerance": "1000 Feet", 126 | "search_criteria": [["Streets", "SHAPE"], ["Streets_ND_Junctions", "SHAPE"]], 127 | "search_query": [["Streets", "ObjectID <> 1"], ["Streets_ND_Junctions", ""]] 128 | } 129 | try: 130 | parallel_calculator = parallel_calculate_locations.ParallelLocationCalculator(**inputs) 131 | parallel_calculator.calc_locs_in_parallel() 132 | self.assertTrue(arcpy.Exists(out_fc), "Output fc does not exist.") 133 | self.assertEqual( 134 | int(arcpy.management.GetCount(self.input_fc).getOutput(0)), 135 | int(arcpy.management.GetCount(out_fc).getOutput(0)), 136 | "Output feature class doesn't have the same number of rows as the original input." 137 | ) 138 | self.check_precalculated_locations(out_fc, check_has_values=True) 139 | finally: 140 | teardown_logger(logger) 141 | 142 | def test_cli(self): 143 | """Test the command line interface.""" 144 | # The input feature class should not be overwritten by this tool, but copy it first just in case. 145 | fc_to_precalculate = os.path.join(self.output_gdb, "PrecalcFC_CLI") 146 | arcpy.management.Copy(self.input_fc, fc_to_precalculate) 147 | out_fc = os.path.join(self.output_gdb, "PrecalcFC_CLI_out") 148 | inputs = [ 149 | os.path.join(sys.exec_prefix, "python.exe"), 150 | os.path.join(os.path.dirname(CWD), "parallel_calculate_locations.py"), 151 | "--input-features", fc_to_precalculate, 152 | "--output-features", out_fc, 153 | "--network-data-source", self.local_nd, 154 | "--chunk-size", "6", 155 | "--max-processes", "4", 156 | "--travel-mode", self.local_tm_time, 157 | "--search-tolerance", "1000 Feet", 158 | "--search-criteria", "Streets SHAPE;Streets_ND_Junctions NONE", 159 | "--search-query", "Streets 'OBJECTID <> 1'" 160 | ] 161 | result = subprocess.run(inputs, check=True) 162 | self.assertEqual(result.returncode, 0) 163 | self.assertTrue(arcpy.Exists(out_fc)) 164 | 165 | 166 | if __name__ == '__main__': 167 | unittest.main() 168 | -------------------------------------------------------------------------------- /unittests/test_parallel_odcm.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the parallel_odcm.py module. 2 | 3 | Copyright 2024 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | # pylint: disable=import-error, protected-access, invalid-name 15 | 16 | import sys 17 | import os 18 | import datetime 19 | import unittest 20 | import pandas as pd 21 | from copy import deepcopy 22 | from glob import glob 23 | import arcpy 24 | 25 | CWD = os.path.dirname(os.path.abspath(__file__)) 26 | sys.path.append(os.path.dirname(CWD)) 27 | import parallel_odcm # noqa: E402, pylint: disable=wrong-import-position 28 | import helpers # noqa: E402, pylint: disable=wrong-import-position 29 | import portal_credentials # noqa: E402, pylint: disable=wrong-import-position 30 | 31 | TEST_ARROW = False 32 | if helpers.arcgis_version >= "2.9": 33 | # The pyarrow module was not included in earlier versions of Pro, and the toArrowTable method was 34 | # added to the ODCostMatrix object at Pro 2.9. Do not attempt to test this output format in 35 | # earlier versions of Pro. 36 | TEST_ARROW = True 37 | import pyarrow as pa 38 | 39 | 40 | class TestParallelODCM(unittest.TestCase): 41 | """Test cases for the parallel_odcm module.""" 42 | 43 | @classmethod 44 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 45 | """Set up shared test properties.""" 46 | self.maxDiff = None 47 | 48 | self.input_data_folder = os.path.join(CWD, "TestInput") 49 | sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 50 | self.origins = os.path.join(sf_gdb, "Analysis", "TractCentroids") 51 | self.destinations = os.path.join(sf_gdb, "Analysis", "Hospitals") 52 | self.local_nd = os.path.join(sf_gdb, "Transportation", "Streets_ND") 53 | self.local_tm_time = "Driving Time" 54 | self.local_tm_dist = "Driving Distance" 55 | self.portal_nd = portal_credentials.PORTAL_URL 56 | self.portal_tm = portal_credentials.PORTAL_TRAVEL_MODE 57 | 58 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 59 | 60 | # Create a unique output directory and gdb for this test 61 | self.scratch_folder = os.path.join( 62 | CWD, "TestOutput", "Output_ParallelODCM_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 63 | os.makedirs(self.scratch_folder) 64 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 65 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 66 | 67 | self.od_args = { 68 | "origins": self.origins, 69 | "destinations": self.destinations, 70 | "output_format": helpers.OutputFormat.featureclass, 71 | "output_od_location": os.path.join(self.output_gdb, "TestOutput"), 72 | "network_data_source": self.local_nd, 73 | "travel_mode": self.local_tm_dist, 74 | "time_units": arcpy.nax.TimeUnits.Minutes, 75 | "distance_units": arcpy.nax.DistanceUnits.Miles, 76 | "cutoff": 2, 77 | "num_destinations": 1, 78 | "time_of_day": None, 79 | "scratch_folder": self.scratch_folder, 80 | "barriers": [] 81 | } 82 | 83 | self.logger = helpers.configure_global_logger(parallel_odcm.LOG_LEVEL) 84 | 85 | self.parallel_od_class_args = { 86 | "logger": self.logger, 87 | "origins": self.origins, 88 | "destinations": self.destinations, 89 | "network_data_source": self.local_nd, 90 | "travel_mode": self.local_tm_dist, 91 | "output_format": "Feature class", 92 | "output_od_location": os.path.join(self.output_gdb, "TestOutput"), 93 | "max_origins": 1000, 94 | "max_destinations": 1000, 95 | "max_processes": 4, 96 | "time_units": "Minutes", 97 | "distance_units": "Miles", 98 | "cutoff": 2, 99 | "num_destinations": 1, 100 | "time_of_day": "20220329 16:45", 101 | "barriers": [] 102 | } 103 | 104 | @classmethod 105 | def tearDownClass(self): 106 | """Deconstruct the logger when tests are finished.""" 107 | helpers.teardown_logger(self.logger) 108 | 109 | def test_ODCostMatrix_hour_to_time_units(self): 110 | """Test the _hour_to_time_units method of the ODCostMatrix class.""" 111 | # Sanity test to make sure the method works for valid units 112 | od_inputs = deepcopy(self.od_args) 113 | od_inputs["time_units"] = arcpy.nax.TimeUnits.Seconds 114 | od = parallel_odcm.ODCostMatrix(**od_inputs) 115 | self.assertEqual(3600, od._hour_to_time_units()) 116 | 117 | def test_ODCostMatrix_mile_to_dist_units(self): 118 | """Test the _mile_to_dist_units method of the ODCostMatrix class.""" 119 | # Sanity test to make sure the method works for valid units 120 | od_inputs = deepcopy(self.od_args) 121 | od_inputs["distance_units"] = arcpy.nax.DistanceUnits.Kilometers 122 | od = parallel_odcm.ODCostMatrix(**od_inputs) 123 | self.assertEqual(1.60934, od._mile_to_dist_units()) 124 | 125 | def test_ODCostMatrix_convert_time_cutoff_to_distance(self): 126 | """Test the _convert_time_cutoff_to_distance method of the ODCostMatrix class.""" 127 | # We start with a 20-minute cutoff. The method converts this to a reasonable distance in units of miles. 128 | od_inputs = deepcopy(self.od_args) 129 | od_inputs["travel_mode"] = self.local_tm_time 130 | od = parallel_odcm.ODCostMatrix(**od_inputs) 131 | self.assertAlmostEqual(28, od._convert_time_cutoff_to_distance(20), 1) 132 | 133 | def test_ODCostMatrix_select_inputs(self): 134 | """Test the _select_inputs method of the ODCostMatrix class.""" 135 | od = parallel_odcm.ODCostMatrix(**self.od_args) 136 | origin_criteria = [1, 2] # Encompasses 2 rows in the southwest corner 137 | 138 | with arcpy.EnvManager(overwriteOutput=True): 139 | # Test when a subset of destinations meets the cutoff criteria 140 | dest_criteria = [8, 12] # Encompasses 5 rows. Two are close to the origins. 141 | od._select_inputs(origin_criteria, dest_criteria) 142 | self.assertEqual(2, int(arcpy.management.GetCount(od.input_origins_layer_obj).getOutput(0))) 143 | # Only two destinations fall within the distance threshold 144 | self.assertEqual(2, int(arcpy.management.GetCount(od.input_destinations_layer_obj).getOutput(0))) 145 | 146 | # Test when none of the destinations are within the threshold 147 | dest_criteria = [14, 17] # Encompasses 4 locations in the far northeast corner 148 | od._select_inputs(origin_criteria, dest_criteria) 149 | self.assertEqual(2, int(arcpy.management.GetCount(od.input_origins_layer_obj).getOutput(0))) 150 | self.assertIsNone( 151 | od.input_destinations_layer_obj, 152 | "Destinations layer should be None since no destinations fall within the straight-line cutoff of origins." 153 | ) 154 | 155 | def test_ODCostMatrix_solve_featureclass(self): 156 | """Test the solve method of the ODCostMatrix class with feature class output.""" 157 | # Initialize an ODCostMatrix analysis object 158 | od = parallel_odcm.ODCostMatrix(**self.od_args) 159 | # Solve a chunk 160 | origin_criteria = [1, 2] # Encompasses 2 rows 161 | dest_criteria = [8, 12] # Encompasses 5 rows 162 | od.solve(origin_criteria, dest_criteria) 163 | # Check results 164 | self.assertIsInstance(od.job_result, dict) 165 | self.assertTrue(od.job_result["solveSucceeded"], "OD solve failed") 166 | self.assertTrue(arcpy.Exists(od.job_result["outputLines"]), "OD line output does not exist.") 167 | self.assertEqual(2, int(arcpy.management.GetCount(od.job_result["outputLines"]).getOutput(0))) 168 | 169 | def test_ODCostMatrix_solve_csv(self): 170 | """Test the solve method of the ODCostMatrix class with csv output.""" 171 | # Initialize an ODCostMatrix analysis object 172 | out_folder = os.path.join(self.scratch_folder, "ODCostMatrix_CSV") 173 | os.mkdir(out_folder) 174 | od_inputs = deepcopy(self.od_args) 175 | od_inputs["output_format"] = helpers.OutputFormat.csv 176 | od_inputs["output_od_location"] = out_folder 177 | od = parallel_odcm.ODCostMatrix(**od_inputs) 178 | # Solve a chunk 179 | origin_criteria = [1, 2] # Encompasses 2 rows 180 | dest_criteria = [8, 12] # Encompasses 5 rows 181 | od.solve(origin_criteria, dest_criteria) 182 | # Check results 183 | self.assertIsInstance(od.job_result, dict) 184 | self.assertTrue(od.job_result["solveSucceeded"], "OD solve failed") 185 | expected_out_file = os.path.join( 186 | out_folder, 187 | f"ODLines_O_{origin_criteria[0]}_{origin_criteria[1]}_D_{dest_criteria[0]}_{dest_criteria[1]}.csv" 188 | ) 189 | self.assertTrue(os.path.exists(od.job_result["outputLines"]), "OD line CSV file output does not exist.") 190 | self.assertEqual(expected_out_file, od.job_result["outputLines"], "OD line CSV file has the wrong filepath.") 191 | row_count = pd.read_csv(expected_out_file).shape[0] 192 | self.assertEqual(2, row_count, "OD line CSV file has an incorrect number of rows.") 193 | 194 | @unittest.skipIf(not TEST_ARROW, "Arrow table output is not available in versions of Pro prior to 2.9.") 195 | def test_ODCostMatrix_solve_arrow(self): 196 | """Test the solve method of the ODCostMatrix class with Arrow output.""" 197 | # Initialize an ODCostMatrix analysis object 198 | out_folder = os.path.join(self.scratch_folder, "ODCostMatrix_Arrow") 199 | os.mkdir(out_folder) 200 | od_inputs = deepcopy(self.od_args) 201 | od_inputs["output_format"] = helpers.OutputFormat.arrow 202 | od_inputs["output_od_location"] = out_folder 203 | od = parallel_odcm.ODCostMatrix(**od_inputs) 204 | # Solve a chunk 205 | origin_criteria = [1, 2] # Encompasses 2 rows 206 | dest_criteria = [8, 12] # Encompasses 5 rows 207 | od.solve(origin_criteria, dest_criteria) 208 | # Check results 209 | self.assertIsInstance(od.job_result, dict) 210 | self.assertTrue(od.job_result["solveSucceeded"], "OD solve failed") 211 | expected_out_file = os.path.join( 212 | out_folder, 213 | f"ODLines_O_{origin_criteria[0]}_{origin_criteria[1]}_D_{dest_criteria[0]}_{dest_criteria[1]}.arrow" 214 | ) 215 | self.assertTrue(os.path.exists(od.job_result["outputLines"]), "OD line Arrow file output does not exist.") 216 | self.assertEqual(expected_out_file, od.job_result["outputLines"], "OD line Arrow file has the wrong filepath.") 217 | with pa.memory_map(expected_out_file, 'r') as source: 218 | batch_reader = pa.ipc.RecordBatchFileReader(source) 219 | arrow_table = batch_reader.read_all() 220 | self.assertEqual(2, arrow_table.num_rows, "OD line Arrow file has an incorrect number of rows.") 221 | 222 | def test_ODCostMatrix_solve_service_featureclass(self): 223 | """Test the solve method of the ODCostMatrix class using a service with feature class output. 224 | 225 | When a service is used, the code does special extra steps to ensure the original origin and 226 | destination OIDs are transferred instead of reset starting with 1 for each chunk. This test 227 | specifically validates this special behavior. 228 | """ 229 | # Initialize an ODCostMatrix analysis object 230 | od_inputs = { 231 | "origins": self.origins, 232 | "destinations": self.destinations, 233 | "output_format": helpers.OutputFormat.featureclass, 234 | "output_od_location": os.path.join(self.output_gdb, "TestOutputService"), 235 | "network_data_source": self.portal_nd, 236 | "travel_mode": self.portal_tm, 237 | "time_units": arcpy.nax.TimeUnits.Minutes, 238 | "distance_units": arcpy.nax.DistanceUnits.Miles, 239 | "cutoff": 10, 240 | "num_destinations": None, 241 | "time_of_day": None, 242 | "scratch_folder": self.scratch_folder, 243 | "barriers": [] 244 | } 245 | od = parallel_odcm.ODCostMatrix(**od_inputs) 246 | # Solve a chunk 247 | origin_criteria = [45, 50] 248 | dest_criteria = [8, 12] 249 | od.solve(origin_criteria, dest_criteria) 250 | self.assertTrue(od.job_result["solveSucceeded"], "OD solve failed") 251 | self.assertTrue(arcpy.Exists(od.job_result["outputLines"]), "OD line output does not exist.") 252 | # Ensure the OriginOID and DestinationOID fields in the output have the correct numerical ranges 253 | fields_to_check = ["OriginOID", "DestinationOID"] 254 | for row in arcpy.da.SearchCursor(od.job_result["outputLines"], fields_to_check): 255 | self.assertTrue(origin_criteria[0] <= row[0] <= origin_criteria[1], f"{fields_to_check[0]} out of range.") 256 | self.assertTrue(dest_criteria[0] <= row[1] <= dest_criteria[1], f"{fields_to_check[1]} out of range.") 257 | 258 | def test_ODCostMatrix_solve_service_csv(self): 259 | """Test the solve method of the ODCostMatrix class using a service with csv output. 260 | 261 | When a service is used, the code does special extra steps to ensure the original origin and 262 | destination OIDs are transferred instead of reset starting with 1 for each chunk. This test 263 | specifically validates this special behavior. 264 | """ 265 | out_folder = os.path.join(self.scratch_folder, "ODCostMatrix_CSV_service") 266 | os.mkdir(out_folder) 267 | # Initialize an ODCostMatrix analysis object 268 | od_inputs = { 269 | "origins": self.origins, 270 | "destinations": self.destinations, 271 | "output_format": helpers.OutputFormat.csv, 272 | "output_od_location": out_folder, 273 | "network_data_source": self.portal_nd, 274 | "travel_mode": self.portal_tm, 275 | "time_units": arcpy.nax.TimeUnits.Minutes, 276 | "distance_units": arcpy.nax.DistanceUnits.Miles, 277 | "cutoff": 10, 278 | "num_destinations": None, 279 | "time_of_day": None, 280 | "scratch_folder": self.scratch_folder, 281 | "barriers": [] 282 | } 283 | od = parallel_odcm.ODCostMatrix(**od_inputs) 284 | # Solve a chunk 285 | origin_criteria = [45, 50] 286 | dest_criteria = [8, 12] 287 | od.solve(origin_criteria, dest_criteria) 288 | self.assertTrue(od.job_result["solveSucceeded"], "OD solve failed") 289 | expected_out_file = os.path.join( 290 | out_folder, 291 | f"ODLines_O_{origin_criteria[0]}_{origin_criteria[1]}_D_{dest_criteria[0]}_{dest_criteria[1]}.csv" 292 | ) 293 | self.assertTrue(os.path.exists(od.job_result["outputLines"]), "OD line CSV file output does not exist.") 294 | self.assertEqual(expected_out_file, od.job_result["outputLines"], "OD line CSV file has the wrong filepath.") 295 | # Ensure the OriginOID and DestinationOID fields in the output have the correct numerical ranges 296 | df = pd.read_csv(expected_out_file) 297 | self.assertTrue(df["OriginOID"].min() >= origin_criteria[0], "OriginOID out of range.") 298 | self.assertTrue(df["OriginOID"].max() <= origin_criteria[1], "OriginOID out of range.") 299 | self.assertTrue(df["DestinationOID"].min() >= dest_criteria[0], "DestinationOID out of range.") 300 | self.assertTrue(df["DestinationOID"].max() <= dest_criteria[1], "DestinationOID out of range.") 301 | 302 | def test_solve_od_cost_matrix(self): 303 | """Test the solve_od_cost_matrix function.""" 304 | result = parallel_odcm.solve_od_cost_matrix([[1, 2], [8, 12]], self.od_args) 305 | # Check results 306 | self.assertIsInstance(result, dict) 307 | self.assertTrue(os.path.exists(result["logFile"]), "Log file does not exist.") 308 | self.assertTrue(result["solveSucceeded"], "OD solve failed") 309 | self.assertTrue(arcpy.Exists(result["outputLines"]), "OD line output does not exist.") 310 | self.assertEqual(2, int(arcpy.management.GetCount(result["outputLines"]).getOutput(0))) 311 | 312 | def test_ParallelODCalculator_validate_od_settings(self): 313 | """Test the _validate_od_settings function.""" 314 | # Test that with good inputs, we return the correct optimized field name 315 | od_calculator = parallel_odcm.ParallelODCalculator(**self.parallel_od_class_args) 316 | optimized_cost_field = od_calculator._validate_od_settings() 317 | self.assertEqual("Total_Distance", optimized_cost_field) 318 | # Test completely invalid travel mode 319 | od_inputs = deepcopy(self.parallel_od_class_args) 320 | od_inputs["travel_mode"] = "InvalidTM" 321 | od_calculator = parallel_odcm.ParallelODCalculator(**od_inputs) 322 | error_type = ValueError if helpers.arcgis_version >= "3.1" else RuntimeError 323 | with self.assertRaises(error_type): 324 | od_calculator._validate_od_settings() 325 | 326 | def test_ParallelODCalculator_solve_od_in_parallel_featureclass(self): 327 | """Test the solve_od_in_parallel function. Output to feature class.""" 328 | out_od_lines = os.path.join(self.output_gdb, "Out_OD_Lines") 329 | inputs = { 330 | "logger": self.logger, 331 | "origins": self.origins, 332 | "destinations": self.destinations, 333 | "network_data_source": self.local_nd, 334 | "travel_mode": self.local_tm_time, 335 | "output_format": "Feature class", 336 | "output_od_location": out_od_lines, 337 | "max_origins": 20, 338 | "max_destinations": 20, 339 | "max_processes": 4, 340 | "time_units": "Minutes", 341 | "distance_units": "Miles", 342 | "cutoff": 30, 343 | "num_destinations": 2, 344 | "time_of_day": None, 345 | "barriers": [] 346 | } 347 | 348 | # Run parallel process. This calculates the OD and also post-processes the results 349 | od_calculator = parallel_odcm.ParallelODCalculator(**inputs) 350 | od_calculator.solve_od_in_parallel() 351 | 352 | # Check results 353 | self.assertTrue(arcpy.Exists(out_od_lines)) 354 | # With 2 destinations for each origin, expect 414 rows in the output 355 | # Note: 1 origin finds no destinations, and that's why we don't have 416. 356 | self.assertEqual(414, int(arcpy.management.GetCount(out_od_lines).getOutput(0))) 357 | 358 | def test_ParallelODCalculator_solve_od_in_parallel_featureclass_no_dest_limit(self): 359 | """Test the solve_od_in_parallel function. Output to feature class. No destination limit. 360 | 361 | A different codepath is used when post-processing OD Line feature classes if there is no destination limit. 362 | """ 363 | out_od_lines = os.path.join(self.output_gdb, "Out_OD_Lines_NoLimit") 364 | inputs = { 365 | "logger": self.logger, 366 | "origins": self.origins, 367 | "destinations": self.destinations, 368 | "network_data_source": self.local_nd, 369 | "travel_mode": self.local_tm_time, 370 | "output_format": "Feature class", 371 | "output_od_location": out_od_lines, 372 | "max_origins": 20, 373 | "max_destinations": 20, 374 | "max_processes": 4, 375 | "time_units": "Minutes", 376 | "distance_units": "Miles", 377 | "cutoff": 30, 378 | "num_destinations": None, 379 | "time_of_day": None, 380 | "barriers": [] 381 | } 382 | 383 | # Run parallel process. This calculates the OD and also post-processes the results 384 | od_calculator = parallel_odcm.ParallelODCalculator(**inputs) 385 | od_calculator.solve_od_in_parallel() 386 | 387 | # Check results 388 | self.assertTrue(arcpy.Exists(out_od_lines)) 389 | self.assertEqual(4545, int(arcpy.management.GetCount(out_od_lines).getOutput(0))) 390 | 391 | def test_ParallelODCalculator_solve_od_in_parallel_csv(self): 392 | """Test the solve_od_in_parallel function. Output to CSV.""" 393 | out_folder = os.path.join(self.scratch_folder, "ParallelODCalculator_CSV") 394 | os.mkdir(out_folder) 395 | inputs = { 396 | "logger": self.logger, 397 | "origins": self.origins, 398 | "destinations": self.destinations, 399 | "network_data_source": self.local_nd, 400 | "travel_mode": self.local_tm_time, 401 | "output_format": "CSV files", 402 | "output_od_location": out_folder, 403 | "max_origins": 20, 404 | "max_destinations": 20, 405 | "max_processes": 4, 406 | "time_units": "Minutes", 407 | "distance_units": "Miles", 408 | "cutoff": 30, 409 | "num_destinations": 2, 410 | "time_of_day": None, 411 | "barriers": [] 412 | } 413 | 414 | # Run parallel process. This calculates the OD and also post-processes the results 415 | od_calculator = parallel_odcm.ParallelODCalculator(**inputs) 416 | od_calculator.solve_od_in_parallel() 417 | 418 | # Check results 419 | csv_files = glob(os.path.join(out_folder, "*.csv")) 420 | self.assertEqual(11, len(csv_files), "Incorrect number of CSV files produced.") 421 | df = pd.concat(map(pd.read_csv, csv_files), ignore_index=True) 422 | # With 2 destinations for each origin, expect 414 rows in the output 423 | # Note: 1 origin finds no destinations, and that's why we don't have 416. 424 | self.assertEqual(414, df.shape[0], "Incorrect number of rows in combined output CSV files.") 425 | 426 | @unittest.skipIf(not TEST_ARROW, "Arrow table output is not available in versions of Pro prior to 2.9.") 427 | def test_ParallelODCalculator_solve_od_in_parallel_arrow(self): 428 | """Test the solve_od_in_parallel function. Output to Arrow files.""" 429 | out_folder = os.path.join(self.scratch_folder, "ParallelODCalculator_Arrow") 430 | os.mkdir(out_folder) 431 | inputs = { 432 | "logger": self.logger, 433 | "origins": self.origins, 434 | "destinations": self.destinations, 435 | "network_data_source": self.local_nd, 436 | "travel_mode": self.local_tm_time, 437 | "output_format": "Apache Arrow files", 438 | "output_od_location": out_folder, 439 | "max_origins": 20, 440 | "max_destinations": 20, 441 | "max_processes": 4, 442 | "time_units": "Minutes", 443 | "distance_units": "Miles", 444 | "cutoff": 30, 445 | "num_destinations": 2, 446 | "time_of_day": None, 447 | "barriers": [] 448 | } 449 | 450 | # Run parallel process. This calculates the OD and also post-processes the results 451 | od_calculator = parallel_odcm.ParallelODCalculator(**inputs) 452 | od_calculator.solve_od_in_parallel() 453 | 454 | # Check results 455 | arrow_files = glob(os.path.join(out_folder, "*.arrow")) 456 | self.assertEqual(11, len(arrow_files), "Incorrect number of CSV files produced.") 457 | arrow_dfs = [] 458 | for arrow_file in arrow_files: 459 | with pa.memory_map(arrow_file, 'r') as source: 460 | batch_reader = pa.ipc.RecordBatchFileReader(source) 461 | chunk_table = batch_reader.read_all() 462 | arrow_dfs.append(chunk_table.to_pandas(split_blocks=True)) 463 | df = pd.concat(arrow_dfs, ignore_index=True) 464 | # With 2 destinations for each origin, expect 414 rows in the output 465 | # Note: 1 origin finds no destinations, and that's why we don't have 416. 466 | self.assertEqual(414, df.shape[0], "Incorrect number of rows in combined output Arrow files.") 467 | 468 | 469 | if __name__ == '__main__': 470 | unittest.main() 471 | -------------------------------------------------------------------------------- /unittests/test_parallel_route_pairs.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the parallel_route_pairs.py module. 2 | 3 | Copyright 2023 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | # pylint: disable=import-error, protected-access, invalid-name 15 | 16 | import sys 17 | import os 18 | import datetime 19 | import subprocess 20 | import unittest 21 | from copy import deepcopy 22 | import pandas as pd 23 | import arcpy 24 | import portal_credentials 25 | import input_data_helper 26 | 27 | CWD = os.path.dirname(os.path.abspath(__file__)) 28 | sys.path.append(os.path.dirname(CWD)) 29 | import parallel_route_pairs # noqa: E402, pylint: disable=wrong-import-position 30 | from helpers import arcgis_version, PreassignedODPairType, configure_global_logger, teardown_logger 31 | 32 | 33 | class TestParallelRoutePairs(unittest.TestCase): 34 | """Test cases for the parallel_route_pairs module.""" 35 | 36 | @classmethod 37 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 38 | """Set up shared test properties.""" 39 | self.maxDiff = None 40 | 41 | self.input_data_folder = os.path.join(CWD, "TestInput") 42 | sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 43 | self.origins = input_data_helper.get_tract_centroids_with_store_id_fc(sf_gdb) 44 | self.destinations = os.path.join(sf_gdb, "Analysis", "Stores") 45 | self.od_pair_table = input_data_helper.get_od_pair_csv(self.input_data_folder) 46 | self.local_nd = os.path.join(sf_gdb, "Transportation", "Streets_ND") 47 | self.local_tm_time = "Driving Time" 48 | self.local_tm_dist = "Driving Distance" 49 | self.portal_nd = portal_credentials.PORTAL_URL 50 | self.portal_tm = portal_credentials.PORTAL_TRAVEL_MODE 51 | 52 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 53 | 54 | # Create a unique output directory and gdb for this test 55 | self.output_folder = os.path.join( 56 | CWD, "TestOutput", "Output_ParallelRoutePairs_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 57 | os.makedirs(self.output_folder) 58 | self.output_gdb = os.path.join(self.output_folder, "outputs.gdb") 59 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 60 | self.logger = configure_global_logger(parallel_route_pairs.LOG_LEVEL) 61 | 62 | self.route_args_one_to_one = { 63 | "pair_type": PreassignedODPairType.one_to_one, 64 | "origins": self.origins, 65 | "origin_id_field": "ID", 66 | "destinations": self.destinations, 67 | "dest_id_field": "NAME", 68 | "network_data_source": self.local_nd, 69 | "travel_mode": self.local_tm_dist, 70 | "time_units": arcpy.nax.TimeUnits.Minutes, 71 | "distance_units": arcpy.nax.DistanceUnits.Miles, 72 | "time_of_day": None, 73 | "reverse_direction": False, 74 | "scratch_folder": self.output_folder, 75 | "assigned_dest_field": "StoreID", 76 | "od_pair_table": None, 77 | "origin_transfer_fields": [], 78 | "destination_transfer_fields": [], 79 | "barriers": [] 80 | } 81 | self.route_args_many_to_many = { 82 | "pair_type": PreassignedODPairType.many_to_many, 83 | "origins": self.origins, 84 | "origin_id_field": "ID", 85 | "destinations": self.destinations, 86 | "dest_id_field": "NAME", 87 | "network_data_source": self.local_nd, 88 | "travel_mode": self.local_tm_dist, 89 | "time_units": arcpy.nax.TimeUnits.Minutes, 90 | "distance_units": arcpy.nax.DistanceUnits.Miles, 91 | "time_of_day": None, 92 | "reverse_direction": False, 93 | "scratch_folder": self.output_folder, 94 | "assigned_dest_field": None, 95 | "od_pair_table": self.od_pair_table, 96 | "origin_transfer_fields": [], 97 | "destination_transfer_fields": [], 98 | "barriers": [] 99 | } 100 | self.parallel_rt_class_args_one_to_one = { 101 | "logger": self.logger, 102 | "pair_type_str": "one_to_one", 103 | "origins": self.origins, 104 | "origin_id_field": "ID", 105 | "destinations": self.destinations, 106 | "dest_id_field": "NAME", 107 | "network_data_source": self.local_nd, 108 | "travel_mode": self.local_tm_dist, 109 | "time_units": "Minutes", 110 | "distance_units": "Miles", 111 | "max_routes": 15, 112 | "max_processes": 4, 113 | "out_routes": os.path.join(self.output_gdb, "OutRoutes_OneToOne"), 114 | "reverse_direction": False, 115 | "scratch_folder": self.output_folder, # Should be set within test if real output will be written 116 | "assigned_dest_field": "StoreID", 117 | "od_pair_table": None, 118 | "time_of_day": "20220329 16:45", 119 | "barriers": "" 120 | } 121 | self.parallel_rt_class_args_many_to_many = { 122 | "logger": self.logger, 123 | "pair_type_str": "many_to_many", 124 | "origins": self.origins, 125 | "origin_id_field": "ID", 126 | "destinations": self.destinations, 127 | "dest_id_field": "NAME", 128 | "network_data_source": self.local_nd, 129 | "travel_mode": self.local_tm_dist, 130 | "time_units": "Minutes", 131 | "distance_units": "Miles", 132 | "max_routes": 15, 133 | "max_processes": 4, 134 | "out_routes": os.path.join(self.output_gdb, "OutRoutes_ManyToMany"), 135 | "reverse_direction": False, 136 | "scratch_folder": self.output_folder, # Should be set within test if real output will be written 137 | "assigned_dest_field": None, 138 | "od_pair_table": self.od_pair_table, 139 | "time_of_day": "20220329 16:45", 140 | "barriers": "" 141 | } 142 | 143 | @classmethod 144 | def tearDownClass(self): 145 | """Deconstruct the logger when tests are finished.""" 146 | teardown_logger(self.logger) 147 | 148 | def test_Route_get_od_pairs_for_chunk(self): 149 | """Test the _get_od_pairs_for_chunk method of the Route class.""" 150 | rt = parallel_route_pairs.Route(**self.route_args_many_to_many) 151 | chunk_size = 10 152 | chunk_num = 3 153 | # Get the third chunk in the OD pairs file 154 | chunk_definition = [chunk_num, chunk_size] 155 | rt._get_od_pairs_for_chunk(chunk_definition) 156 | # Make sure we have the right number of OD pairs 157 | self.assertEqual(chunk_size, len(rt.od_pairs)) 158 | # Verify that the solver's OD pairs are the right ones. 159 | df_od_pairs = pd.read_csv( 160 | self.od_pair_table, 161 | header=None, 162 | dtype=str 163 | ) 164 | chunk_start = chunk_num * chunk_size 165 | expected_od_pairs = df_od_pairs.loc[chunk_start:chunk_start + chunk_size - 1].values.tolist() 166 | self.assertEqual(expected_od_pairs, rt.od_pairs) 167 | 168 | def test_Route_select_inputs_one_to_one(self): 169 | """Test the _select_inputs_one_to_one method of the Route class.""" 170 | rt = parallel_route_pairs.Route(**self.route_args_one_to_one) 171 | origin_criteria = [4, 9] # Encompasses 6 rows 172 | rt._select_inputs_one_to_one(origin_criteria) 173 | self.assertEqual(6, int(arcpy.management.GetCount(rt.input_origins_layer_obj).getOutput(0))) 174 | 175 | def test_Route_select_inputs_many_to_many_str(self): 176 | """Test the _select_inputs_many_to_many method of the Route class using string-type ID fields.""" 177 | rt = parallel_route_pairs.Route(**self.route_args_many_to_many) 178 | rt.od_pairs = [ # 3 unique origins, 5 unique destinations 179 | ["06075060700", "Store_19"], 180 | ["06081601400", "Store_7"], 181 | ["06081601400", "Store_15"], 182 | ["06081601400", "Store_2"], 183 | ["06075023001", "Store_15"], 184 | ["06075023001", "Store_25"] 185 | ] 186 | rt._select_inputs_many_to_many() 187 | self.assertEqual(3, int(arcpy.management.GetCount(rt.input_origins_layer_obj).getOutput(0))) 188 | self.assertEqual(5, int(arcpy.management.GetCount(rt.input_dests_layer_obj).getOutput(0))) 189 | 190 | def test_Route_select_inputs_many_to_many_num(self): 191 | """Test the _select_inputs_many_to_many method of the Route class using numerical ID fields.""" 192 | rt = parallel_route_pairs.Route(**self.route_args_many_to_many) 193 | rt.origin_id_field = "ObjectID" 194 | rt.dest_id_field = "ObjectID" 195 | rt.od_pairs = [ # 3 unique origins, 5 unique destinations 196 | [2, 16], 197 | [4, 19], 198 | [4, 5], 199 | [4, 10], 200 | [7, 5], 201 | [7, 25] 202 | ] 203 | rt._select_inputs_many_to_many() 204 | self.assertEqual(3, int(arcpy.management.GetCount(rt.input_origins_layer_obj).getOutput(0))) 205 | self.assertEqual(5, int(arcpy.management.GetCount(rt.input_dests_layer_obj).getOutput(0))) 206 | 207 | def test_Route_solve_one_to_one(self): 208 | """Test the solve method of the Route class with feature class output for the one-to-one pair type.""" 209 | # Initialize an Route analysis object 210 | rt = parallel_route_pairs.Route(**self.route_args_one_to_one) 211 | # Solve a chunk 212 | origin_criteria = [2, 12] # 11 rows 213 | rt.solve(origin_criteria) 214 | # Check results 215 | self.assertIsInstance(rt.job_result, dict) 216 | self.assertTrue(rt.job_result["solveSucceeded"], "Route solve failed") 217 | self.assertTrue(arcpy.Exists(rt.job_result["outputRoutes"]), "Route output does not exist.") 218 | # Expect 9 rows because two of the StoreID values are bad and are skipped 219 | self.assertEqual(9, int(arcpy.management.GetCount(rt.job_result["outputRoutes"]).getOutput(0))) 220 | # Make sure the ID fields have been added and populated 221 | route_fields = [f.name for f in arcpy.ListFields(rt.job_result["outputRoutes"])] 222 | self.assertIn("OriginUniqueID", route_fields, "Routes output missing OriginUniqueID field.") 223 | self.assertIn("DestinationUniqueID", route_fields, "Routes output missing DestinationUniqueID field.") 224 | for row in arcpy.da.SearchCursor( # pylint: disable=no-member 225 | rt.job_result["outputRoutes"], ["OriginUniqueID", "DestinationUniqueID"] 226 | ): 227 | self.assertIsNotNone(row[0], "Null OriginUniqueID field value in output routes.") 228 | self.assertIsNotNone(row[1], "Null DestinationUniqueID field value in output routes.") 229 | 230 | def test_Route_solve_many_to_many(self): 231 | """Test the solve method of the Route class with feature class output for the many-to-many pair type.""" 232 | # Initialize an Route analysis object 233 | rt = parallel_route_pairs.Route(**self.route_args_many_to_many) 234 | # Solve a chunk 235 | chunk_size = 10 236 | chunk_num = 2 # Corresponds to OD pairs 2-20 237 | chunk_definition = [chunk_num, chunk_size] 238 | rt.solve(chunk_definition) 239 | # Check results 240 | self.assertIsInstance(rt.job_result, dict) 241 | self.assertTrue(rt.job_result["solveSucceeded"], "Route solve failed") 242 | self.assertTrue(arcpy.Exists(rt.job_result["outputRoutes"]), "Route output does not exist.") 243 | # Check for correct number of routes 244 | self.assertEqual(chunk_size, int(arcpy.management.GetCount(rt.job_result["outputRoutes"]).getOutput(0))) 245 | # Make sure the ID fields have been added and populated 246 | route_fields = [f.name for f in arcpy.ListFields(rt.job_result["outputRoutes"])] 247 | self.assertIn("OriginUniqueID", route_fields, "Routes output missing OriginUniqueID field.") 248 | self.assertIn("DestinationUniqueID", route_fields, "Routes output missing DestinationUniqueID field.") 249 | df_od_pairs = pd.read_csv( 250 | self.od_pair_table, 251 | header=None, 252 | skiprows=chunk_size*chunk_num, 253 | nrows=chunk_size, 254 | dtype=str 255 | ) 256 | expected_origin_ids = df_od_pairs[0].unique().tolist() 257 | expected_dest_ids = df_od_pairs[1].unique().tolist() 258 | for row in arcpy.da.SearchCursor( # pylint: disable=no-member 259 | rt.job_result["outputRoutes"], ["OriginUniqueID", "DestinationUniqueID"] 260 | ): 261 | self.assertIsNotNone(row[0], "Null OriginUniqueID field value in output routes.") 262 | self.assertIn(row[0], expected_origin_ids, "OriginUniqueID does not match expected list of IDs.") 263 | self.assertIsNotNone(row[1], "Null DestinationUniqueID field value in output routes.") 264 | self.assertIn(row[1], expected_dest_ids, "DestinationUniqueID does not match expected list of IDs.") 265 | 266 | def test_Route_solve_service_one_to_one(self): 267 | """Test the solve method of the Route class with feature class output using a service.""" 268 | # Initialize an Route analysis object 269 | route_args = deepcopy(self.route_args_one_to_one) 270 | route_args["network_data_source"] = self.portal_nd 271 | route_args["travel_mode"] = self.portal_tm 272 | rt = parallel_route_pairs.Route(**route_args) 273 | # Solve a chunk 274 | origin_criteria = [2, 12] # 11 rows 275 | rt.solve(origin_criteria) 276 | # Check results 277 | self.assertIsInstance(rt.job_result, dict) 278 | self.assertTrue(rt.job_result["solveSucceeded"], "Route solve failed") 279 | self.assertTrue(arcpy.Exists(rt.job_result["outputRoutes"]), "Route output does not exist.") 280 | # Expect 9 rows because two of the StoreID values are bad and are skipped 281 | self.assertEqual(9, int(arcpy.management.GetCount(rt.job_result["outputRoutes"]).getOutput(0))) 282 | # Make sure the ID fields have been added and populated 283 | route_fields = [f.name for f in arcpy.ListFields(rt.job_result["outputRoutes"])] 284 | self.assertIn("OriginUniqueID", route_fields, "Routes output missing OriginUniqueID field.") 285 | self.assertIn("DestinationUniqueID", route_fields, "Routes output missing DestinationUniqueID field.") 286 | for row in arcpy.da.SearchCursor( # pylint: disable=no-member 287 | rt.job_result["outputRoutes"], ["OriginUniqueID", "DestinationUniqueID"] 288 | ): 289 | self.assertIsNotNone(row[0], "Null OriginUniqueID field value in output routes.") 290 | self.assertIsNotNone(row[1], "Null DestinationUniqueID field value in output routes.") 291 | 292 | def test_ParallelRoutePairCalculator_validate_route_settings(self): 293 | """Test the _validate_route_settings function.""" 294 | # Test that with good inputs, we return the correct optimized field name 295 | rt_calculator = parallel_route_pairs.ParallelRoutePairCalculator(**self.parallel_rt_class_args_one_to_one) 296 | rt_calculator._validate_route_settings() 297 | # Test completely invalid travel mode 298 | rt_inputs = deepcopy(self.parallel_rt_class_args_one_to_one) 299 | rt_inputs["travel_mode"] = "InvalidTM" 300 | rt_calculator = parallel_route_pairs.ParallelRoutePairCalculator(**rt_inputs) 301 | error_type = ValueError if arcgis_version >= "3.1" else RuntimeError 302 | with self.assertRaises(error_type): 303 | rt_calculator._validate_route_settings() 304 | 305 | def test_ParallelRoutePairCalculator_solve_route_in_parallel_one_to_one(self): 306 | """Test the solve_od_in_parallel function with the one-to-one pair type. Output to feature class.""" 307 | out_routes = os.path.join(self.output_gdb, "Out_Combined_Routes_OneToOne") 308 | scratch_folder = os.path.join(self.output_folder, "Out_Combined_Routes_OneToOne") 309 | os.mkdir(scratch_folder) 310 | rt_inputs = deepcopy(self.parallel_rt_class_args_one_to_one) 311 | rt_inputs["out_routes"] = out_routes 312 | rt_inputs["scratch_folder"] = scratch_folder 313 | 314 | # Run parallel process. This calculates the OD and also post-processes the results 315 | rt_calculator = parallel_route_pairs.ParallelRoutePairCalculator(**rt_inputs) 316 | rt_calculator.solve_route_in_parallel() 317 | 318 | # Check results 319 | self.assertTrue(arcpy.Exists(out_routes)) 320 | # There are 208 tract centroids, but three of them have null or invalid assigned destinations 321 | self.assertEqual(205, int(arcpy.management.GetCount(out_routes).getOutput(0))) 322 | 323 | def test_ParallelRoutePairCalculator_solve_route_in_parallel_many_to_many(self): 324 | """Test the solve_od_in_parallel function with the many-to-many pair type. Output to feature class.""" 325 | out_routes = os.path.join(self.output_gdb, "Out_Combined_Routes_ManyToMany") 326 | scratch_folder = os.path.join(self.output_folder, "Out_Combined_Routes_ManyToMany") 327 | os.mkdir(scratch_folder) 328 | rt_inputs = deepcopy(self.parallel_rt_class_args_many_to_many) 329 | rt_inputs["out_routes"] = out_routes 330 | rt_inputs["scratch_folder"] = scratch_folder 331 | 332 | # Run parallel process. This calculates the OD and also post-processes the results 333 | rt_calculator = parallel_route_pairs.ParallelRoutePairCalculator(**rt_inputs) 334 | rt_calculator.solve_route_in_parallel() 335 | 336 | # Check results 337 | self.assertTrue(arcpy.Exists(out_routes)) 338 | # The CSV file has 63 lines, so we should get 63 routes. 339 | self.assertEqual(63, int(arcpy.management.GetCount(out_routes).getOutput(0))) 340 | 341 | def test_cli_one_to_one(self): 342 | """Test the command line interface of and launch_parallel_rt_pairs function for the one-to-one pair type.""" 343 | out_folder = os.path.join(self.output_folder, "CLI_Output_OneToOne") 344 | os.mkdir(out_folder) 345 | out_routes = os.path.join(self.output_gdb, "OutCLIRoutes_OneToOne") 346 | rt_inputs = [ 347 | os.path.join(sys.exec_prefix, "python.exe"), 348 | os.path.join(os.path.dirname(CWD), "parallel_route_pairs.py"), 349 | "--pair-type", "one_to_one", 350 | "--origins", self.origins, 351 | "--origins-id-field", "ID", 352 | "--destinations", self.destinations, 353 | "--destinations-id-field", "NAME", 354 | "--network-data-source", self.local_nd, 355 | "--travel-mode", self.local_tm_dist, 356 | "--time-units", "Minutes", 357 | "--distance-units", "Miles", 358 | "--max-routes", "15", 359 | "--max-processes", "4", 360 | "--out-routes", out_routes, 361 | "--reverse-direction", "false", 362 | "--scratch-folder", out_folder, 363 | "--assigned-dest-field", "StoreID", 364 | "--time-of-day", "20220329 16:45" 365 | ] 366 | result = subprocess.run(rt_inputs, check=True) 367 | self.assertEqual(result.returncode, 0) 368 | self.assertTrue(arcpy.Exists(out_routes)) 369 | 370 | def test_cli_many_to_many(self): 371 | """Test the command line interface of and launch_parallel_rt_pairs function for the many-to-many.""" 372 | out_folder = os.path.join(self.output_folder, "CLI_Output_ManyToMany") 373 | os.mkdir(out_folder) 374 | out_routes = os.path.join(self.output_gdb, "OutCLIRoutes_ManyToMany") 375 | rt_inputs = [ 376 | os.path.join(sys.exec_prefix, "python.exe"), 377 | os.path.join(os.path.dirname(CWD), "parallel_route_pairs.py"), 378 | "--pair-type", "many_to_many", 379 | "--origins", self.origins, 380 | "--origins-id-field", "ID", 381 | "--destinations", self.destinations, 382 | "--destinations-id-field", "NAME", 383 | "--network-data-source", self.local_nd, 384 | "--travel-mode", self.local_tm_dist, 385 | "--time-units", "Minutes", 386 | "--distance-units", "Miles", 387 | "--max-routes", "15", 388 | "--max-processes", "4", 389 | "--out-routes", out_routes, 390 | "--reverse-direction", "false", 391 | "--scratch-folder", out_folder, 392 | "--od-pair-table", self.od_pair_table, 393 | "--time-of-day", "20220329 16:45" 394 | ] 395 | result = subprocess.run(rt_inputs, check=True) 396 | self.assertEqual(result.returncode, 0) 397 | self.assertTrue(arcpy.Exists(out_routes)) 398 | 399 | 400 | if __name__ == '__main__': 401 | unittest.main() 402 | -------------------------------------------------------------------------------- /unittests/test_solve_large_odcm.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the solve_large_odcm.py module. 2 | 3 | Copyright 2023 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | # pylint: disable=import-error, protected-access, invalid-name 15 | 16 | import sys 17 | import os 18 | import datetime 19 | import subprocess 20 | import unittest 21 | from copy import deepcopy 22 | from glob import glob 23 | import arcpy 24 | import portal_credentials # Contains log-in for an ArcGIS Online account to use as a test portal 25 | 26 | CWD = os.path.dirname(os.path.abspath(__file__)) 27 | sys.path.append(os.path.dirname(CWD)) 28 | import solve_large_odcm # noqa: E402, pylint: disable=wrong-import-position 29 | import helpers # noqa: E402, pylint: disable=wrong-import-position 30 | from helpers import arcgis_version, MAX_ALLOWED_MAX_PROCESSES # noqa: E402, pylint: disable=wrong-import-position 31 | 32 | 33 | class TestSolveLargeODCM(unittest.TestCase): 34 | """Test cases for the solve_large_odcm module.""" 35 | 36 | @classmethod 37 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 38 | self.maxDiff = None 39 | 40 | self.input_data_folder = os.path.join(CWD, "TestInput") 41 | self.sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 42 | self.origins = os.path.join(self.sf_gdb, "Analysis", "TractCentroids") 43 | self.destinations = os.path.join(self.sf_gdb, "Analysis", "Hospitals") 44 | self.local_nd = os.path.join(self.sf_gdb, "Transportation", "Streets_ND") 45 | self.local_tm_time = "Driving Time" 46 | self.local_tm_dist = "Driving Distance" 47 | self.portal_nd = portal_credentials.PORTAL_URL 48 | self.portal_tm = portal_credentials.PORTAL_TRAVEL_MODE 49 | self.time_of_day_str = "20220329 16:45" 50 | 51 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 52 | 53 | # Create a unique output directory and gdb for this test 54 | self.scratch_folder = os.path.join( 55 | CWD, "TestOutput", "Output_SolveLargeODCM_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 56 | os.makedirs(self.scratch_folder) 57 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 58 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 59 | 60 | # Copy some data to the output gdb to serve as barriers. Do not use tutorial data directly as input because 61 | # the tests will write network location fields to it, and we don't want to modify the user's original data. 62 | self.barriers = os.path.join(self.output_gdb, "Barriers") 63 | arcpy.management.Copy(os.path.join(self.sf_gdb, "Analysis", "CentralDepots"), self.barriers) 64 | 65 | self.od_args = { 66 | "origins": self.origins, 67 | "destinations": self.destinations, 68 | "network_data_source": self.local_nd, 69 | "travel_mode": self.local_tm_dist, 70 | "output_origins": os.path.join(self.output_gdb, "OutOrigins"), 71 | "output_destinations": os.path.join(self.output_gdb, "OutDestinations"), 72 | "chunk_size": 20, 73 | "max_processes": 4, 74 | "time_units": "Minutes", 75 | "distance_units": "Miles", 76 | "output_format": "Feature class", 77 | "output_od_lines": os.path.join(self.output_gdb, "OutODLines"), 78 | "output_data_folder": None, 79 | "cutoff": 2, 80 | "num_destinations": 1, 81 | "time_of_day": None, 82 | "precalculate_network_locations": True, 83 | "sort_inputs": True, 84 | "barriers": [self.barriers] 85 | } 86 | 87 | def test_validate_inputs(self): 88 | """Test the validate_inputs function.""" 89 | does_not_exist = os.path.join(self.sf_gdb, "Analysis", "DoesNotExist") 90 | invalid_inputs = [ 91 | ("chunk_size", -5, ValueError, "Chunk size must be greater than 0."), 92 | ("max_processes", 0, ValueError, "Maximum allowed parallel processes must be greater than 0."), 93 | ("max_processes", 5000, ValueError, ( 94 | f"The maximum allowed parallel processes cannot exceed {MAX_ALLOWED_MAX_PROCESSES:} due " 95 | "to limitations imposed by Python's concurrent.futures module." 96 | )), 97 | ("time_units", "BadUnits", ValueError, "Invalid time units: BadUnits"), 98 | ("distance_units", "BadUnits", ValueError, "Invalid distance units: BadUnits"), 99 | ("origins", does_not_exist, ValueError, f"Input dataset {does_not_exist} does not exist."), 100 | ("destinations", does_not_exist, ValueError, f"Input dataset {does_not_exist} does not exist."), 101 | ("barriers", [does_not_exist], ValueError, f"Input dataset {does_not_exist} does not exist."), 102 | ("network_data_source", does_not_exist, ValueError, 103 | f"Input network dataset {does_not_exist} does not exist."), 104 | ("travel_mode", "BadTM", ValueError if arcgis_version >= "3.1" else RuntimeError, ""), 105 | ("time_of_day", "3/29/2022 4:45 PM", ValueError, ""), 106 | ("time_of_day", "BadDateTime", ValueError, ""), 107 | ("cutoff", 0, ValueError, "Impedance cutoff must be greater than 0."), 108 | ("cutoff", -5, ValueError, "Impedance cutoff must be greater than 0."), 109 | ("num_destinations", 0, ValueError, "Number of destinations to find must be greater than 0."), 110 | ("num_destinations", -5, ValueError, "Number of destinations to find must be greater than 0."), 111 | ] 112 | for invalid_input in invalid_inputs: 113 | property_name, value, error_type, expected_message = invalid_input 114 | with self.subTest( 115 | property_name=property_name, value=value, error_type=error_type, expected_message=expected_message 116 | ): 117 | inputs = deepcopy(self.od_args) 118 | inputs[property_name] = value 119 | od_solver = solve_large_odcm.ODCostMatrixSolver(**inputs) 120 | with self.assertRaises(error_type) as ex: 121 | od_solver._validate_inputs() 122 | if expected_message: 123 | self.assertEqual(expected_message, str(ex.exception)) 124 | 125 | # Check validation of missing output feature class or folder location depending on output format 126 | for output_format in helpers.OUTPUT_FORMATS: 127 | if output_format == "Feature class": 128 | output_od_lines = None 129 | output_data_folder = "Stuff" 130 | else: 131 | output_od_lines = "Stuff" 132 | output_data_folder = None 133 | with self.subTest(output_format=output_format): 134 | inputs = deepcopy(self.od_args) 135 | inputs["output_format"] = output_format 136 | inputs["output_od_lines"] = output_od_lines 137 | inputs["output_data_folder"] = output_data_folder 138 | od_solver = solve_large_odcm.ODCostMatrixSolver(**inputs) 139 | with self.assertRaises(ValueError): 140 | od_solver._validate_inputs() 141 | 142 | # Check validation when the network data source is a service and Arrow output is requested 143 | # Arrow output from services is not yet supported. 144 | output_format = "Apache Arrow files" 145 | with self.subTest(output_format=output_format, network_data_source=self.portal_nd): 146 | inputs = deepcopy(self.od_args) 147 | inputs["output_format"] = output_format 148 | inputs["output_data_folder"] = "Stuff" 149 | inputs["network_data_source"] = self.portal_nd 150 | od_solver = solve_large_odcm.ODCostMatrixSolver(**inputs) 151 | with self.assertRaises(ValueError): 152 | od_solver._validate_inputs() 153 | 154 | def test_update_max_inputs_for_service(self): 155 | """Test the update_max_inputs_for_service function.""" 156 | max_origins = 1500 157 | max_destinations = 700 158 | 159 | inputs = deepcopy(self.od_args) 160 | inputs["network_data_source"] = self.portal_nd 161 | inputs["travel_mode"] = self.portal_tm 162 | od_solver = solve_large_odcm.ODCostMatrixSolver(**inputs) 163 | od_solver.max_origins = max_origins 164 | od_solver.max_destinations = max_destinations 165 | tool_limits = { 166 | 'forceHierarchyBeyondDistance': 50.0, 167 | 'forceHierarchyBeyondDistanceUnits': 168 | 'Miles', 169 | 'maximumDestinations': 1000.0, 170 | 'maximumFeaturesAffectedByLineBarriers': 500.0, 171 | 'maximumFeaturesAffectedByPointBarriers': 250.0, 172 | 'maximumFeaturesAffectedByPolygonBarriers': 2000.0, 173 | 'maximumGeodesicDistanceUnitsWhenWalking': 'Miles', 174 | 'maximumGeodesicDistanceWhenWalking': 27.0, 175 | 'maximumOrigins': 1000.0 176 | } 177 | od_solver.service_limits = tool_limits 178 | od_solver._update_max_inputs_for_service() 179 | self.assertEqual(tool_limits["maximumOrigins"], od_solver.max_origins) 180 | self.assertEqual(max_destinations, od_solver.max_destinations) 181 | 182 | # Test when there are no limits 183 | tool_limits = { 184 | 'forceHierarchyBeyondDistance': 50.0, 185 | 'forceHierarchyBeyondDistanceUnits': 186 | 'Miles', 187 | 'maximumDestinations': None, 188 | 'maximumFeaturesAffectedByLineBarriers': 500.0, 189 | 'maximumFeaturesAffectedByPointBarriers': 250.0, 190 | 'maximumFeaturesAffectedByPolygonBarriers': 2000.0, 191 | 'maximumGeodesicDistanceUnitsWhenWalking': 'Miles', 192 | 'maximumGeodesicDistanceWhenWalking': 27.0, 193 | 'maximumOrigins': None 194 | } 195 | od_solver.max_origins = max_origins 196 | od_solver.max_destinations = max_destinations 197 | od_solver.service_limits = tool_limits 198 | od_solver._update_max_inputs_for_service() 199 | self.assertEqual(max_origins, od_solver.max_origins) 200 | self.assertEqual(max_destinations, od_solver.max_destinations) 201 | 202 | def test_add_tracked_oid_field(self): 203 | """Test the _add_tracked_oid_field method.""" 204 | od_solver = solve_large_odcm.ODCostMatrixSolver(**self.od_args) 205 | fc_to_modify = os.path.join(self.output_gdb, "TrackedID") 206 | arcpy.management.Copy(self.destinations, fc_to_modify) 207 | od_solver._add_tracked_oid_field(fc_to_modify, "DestinationOID") 208 | self.assertTrue(arcpy.Exists(fc_to_modify)) 209 | self.assertIn("DestinationOID", [f.name for f in arcpy.ListFields(fc_to_modify)]) 210 | 211 | def test_spatially_sort_input(self): 212 | """Test the _spatially_sort_input method.""" 213 | od_solver = solve_large_odcm.ODCostMatrixSolver(**self.od_args) 214 | fc_to_sort = os.path.join(self.output_gdb, "Sorted") 215 | arcpy.management.Copy(self.destinations, fc_to_sort) 216 | od_solver._spatially_sort_input(fc_to_sort) 217 | self.assertTrue(arcpy.Exists(fc_to_sort)) 218 | 219 | def test_solve_large_od_cost_matrix_featureclass(self): 220 | """Test the full solve OD Cost Matrix workflow with feature class output.""" 221 | od_solver = solve_large_odcm.ODCostMatrixSolver(**self.od_args) 222 | od_solver.solve_large_od_cost_matrix() 223 | self.assertTrue(arcpy.Exists(self.od_args["output_od_lines"])) 224 | self.assertTrue(arcpy.Exists(self.od_args["output_origins"])) 225 | self.assertTrue(arcpy.Exists(self.od_args["output_destinations"])) 226 | 227 | def test_solve_large_od_cost_matrix_same_inputs_csv(self): 228 | """Test the full solve OD Cost Matrix workflow when origins and destinations are the same. Use CSV outputs.""" 229 | out_folder = os.path.join(self.scratch_folder, "FullWorkflow_CSV_SameInputs") 230 | os.mkdir(out_folder) 231 | od_args = { 232 | "origins": self.destinations, 233 | "destinations": self.destinations, 234 | "network_data_source": self.local_nd, 235 | "travel_mode": self.local_tm_dist, 236 | "output_origins": os.path.join(self.output_gdb, "OutOriginsSame"), 237 | "output_destinations": os.path.join(self.output_gdb, "OutDestinationsSame"), 238 | "chunk_size": 50, 239 | "max_processes": 4, 240 | "time_units": "Minutes", 241 | "distance_units": "Miles", 242 | "output_format": "CSV files", 243 | "output_od_lines": None, 244 | "output_data_folder": out_folder, 245 | "cutoff": 2, 246 | "num_destinations": 2, 247 | "time_of_day": self.time_of_day_str, 248 | "precalculate_network_locations": True, 249 | "sort_inputs": True, 250 | "barriers": "" 251 | } 252 | od_solver = solve_large_odcm.ODCostMatrixSolver(**od_args) 253 | od_solver.solve_large_od_cost_matrix() 254 | self.assertTrue(arcpy.Exists(od_args["output_origins"])) 255 | self.assertTrue(arcpy.Exists(od_args["output_destinations"])) 256 | csv_files = glob(os.path.join(out_folder, "*.csv")) 257 | self.assertGreater(len(csv_files), 0) 258 | 259 | def test_solve_large_od_cost_matrix_arrow(self): 260 | """Test the full solve OD Cost Matrix workflow with Arrow table outputs""" 261 | out_folder = os.path.join(self.scratch_folder, "FullWorkflow_Arrow") 262 | os.mkdir(out_folder) 263 | od_args = { 264 | "origins": self.origins, 265 | "destinations": self.destinations, 266 | "network_data_source": self.local_nd, 267 | "travel_mode": self.local_tm_dist, 268 | "output_origins": os.path.join(self.output_gdb, "OutOriginsArrow"), 269 | "output_destinations": os.path.join(self.output_gdb, "OutDestinationsArrow"), 270 | "chunk_size": 50, 271 | "max_processes": 4, 272 | "time_units": "Minutes", 273 | "distance_units": "Miles", 274 | "output_format": "Apache Arrow files", 275 | "output_od_lines": None, 276 | "output_data_folder": out_folder, 277 | "cutoff": 2, 278 | "num_destinations": 2, 279 | "time_of_day": None, 280 | "precalculate_network_locations": True, 281 | "sort_inputs": True, 282 | "barriers": "" 283 | } 284 | od_solver = solve_large_odcm.ODCostMatrixSolver(**od_args) 285 | od_solver.solve_large_od_cost_matrix() 286 | self.assertTrue(arcpy.Exists(od_args["output_origins"])) 287 | self.assertTrue(arcpy.Exists(od_args["output_destinations"])) 288 | arrow_files = glob(os.path.join(out_folder, "*.arrow")) 289 | self.assertGreater(len(arrow_files), 0) 290 | 291 | def test_solve_large_od_cost_matrix_no_sorting(self): 292 | """Test the full solve OD Cost Matrix workflow without spatially sorting inputs. Use CSV outputs.""" 293 | out_folder = os.path.join(self.scratch_folder, "FullWorkflow_CSV_NoSort") 294 | os.mkdir(out_folder) 295 | od_args = { 296 | "origins": self.origins, 297 | "destinations": self.destinations, 298 | "network_data_source": self.local_nd, 299 | "travel_mode": self.local_tm_dist, 300 | "output_origins": os.path.join(self.output_gdb, "OutUnsortedOrigins"), 301 | "output_destinations": os.path.join(self.output_gdb, "OutUnsortedDestinations"), 302 | "chunk_size": 50, 303 | "max_processes": 4, 304 | "time_units": "Minutes", 305 | "distance_units": "Miles", 306 | "output_format": "CSV files", 307 | "output_od_lines": None, 308 | "output_data_folder": out_folder, 309 | "cutoff": 2, 310 | "num_destinations": 2, 311 | "time_of_day": self.time_of_day_str, 312 | "precalculate_network_locations": True, 313 | "sort_inputs": False, 314 | "barriers": "" 315 | } 316 | od_solver = solve_large_odcm.ODCostMatrixSolver(**od_args) 317 | od_solver.solve_large_od_cost_matrix() 318 | self.assertTrue(arcpy.Exists(od_args["output_origins"])) 319 | self.assertTrue(arcpy.Exists(od_args["output_destinations"])) 320 | csv_files = glob(os.path.join(out_folder, "*.csv")) 321 | self.assertGreater(len(csv_files), 0) 322 | 323 | def test_cli(self): 324 | """Test the command line interface of solve_large_odcm.""" 325 | out_folder = os.path.join(self.scratch_folder, "CLI_CSV_Output") 326 | os.mkdir(out_folder) 327 | odcm_inputs = [ 328 | os.path.join(sys.exec_prefix, "python.exe"), 329 | os.path.join(os.path.dirname(CWD), "solve_large_odcm.py"), 330 | "--origins", self.origins, 331 | "--destinations", self.destinations, 332 | "--output-origins", os.path.join(self.output_gdb, "OutOriginsCLI"), 333 | "--output-destinations", os.path.join(self.output_gdb, "OutDestinationsCLI"), 334 | "--network-data-source", self.local_nd, 335 | "--travel-mode", self.local_tm_time, 336 | "--time-units", "Minutes", 337 | "--distance-units", "Miles", 338 | "--output-format", "CSV files", 339 | "--output-data-folder", out_folder, 340 | "--chunk-size", "50", 341 | "--max-processes", "4", 342 | "--cutoff", "10", 343 | "--num-destinations", "1", 344 | "--time-of-day", self.time_of_day_str, 345 | "--precalculate-network-locations", "true", 346 | "--sort-inputs", "true", 347 | "--barriers", self.barriers 348 | ] 349 | result = subprocess.run(odcm_inputs) 350 | self.assertEqual(result.returncode, 0) 351 | 352 | 353 | if __name__ == '__main__': 354 | unittest.main() 355 | -------------------------------------------------------------------------------- /unittests/test_solve_large_route_pair_analysis.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the solve_large_route_pair_analysis.py module. 2 | 3 | Copyright 2023 Esri 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | # pylint: disable=import-error, protected-access, invalid-name 15 | 16 | import sys 17 | import os 18 | import datetime 19 | import subprocess 20 | import unittest 21 | from copy import deepcopy 22 | import arcpy 23 | import portal_credentials # Contains log-in for an ArcGIS Online account to use as a test portal 24 | import input_data_helper 25 | 26 | CWD = os.path.dirname(os.path.abspath(__file__)) 27 | sys.path.append(os.path.dirname(CWD)) 28 | import solve_large_route_pair_analysis # noqa: E402, pylint: disable=wrong-import-position 29 | from helpers import arcgis_version, PreassignedODPairType, \ 30 | MAX_ALLOWED_MAX_PROCESSES # noqa: E402, pylint: disable=wrong-import-position 31 | 32 | 33 | class TestSolveLargeRoutePairAnalysis(unittest.TestCase): 34 | """Test cases for the solve_large_route_pair_analysis module.""" 35 | 36 | @classmethod 37 | def setUpClass(self): # pylint: disable=bad-classmethod-argument 38 | self.maxDiff = None 39 | 40 | self.input_data_folder = os.path.join(CWD, "TestInput") 41 | self.sf_gdb = os.path.join(self.input_data_folder, "SanFrancisco.gdb") 42 | self.origins = input_data_helper.get_tract_centroids_with_store_id_fc(self.sf_gdb) 43 | self.destinations = os.path.join(self.sf_gdb, "Analysis", "Stores") 44 | self.od_pairs_table = input_data_helper.get_od_pairs_fgdb_table(self.sf_gdb) 45 | self.local_nd = os.path.join(self.sf_gdb, "Transportation", "Streets_ND") 46 | self.local_tm_time = "Driving Time" 47 | self.local_tm_dist = "Driving Distance" 48 | self.portal_nd = portal_credentials.PORTAL_URL 49 | self.portal_tm = portal_credentials.PORTAL_TRAVEL_MODE 50 | self.time_of_day_str = "20220329 16:45" 51 | 52 | arcpy.SignInToPortal(self.portal_nd, portal_credentials.PORTAL_USERNAME, portal_credentials.PORTAL_PASSWORD) 53 | 54 | # Create a unique output directory and gdb for this test 55 | self.scratch_folder = os.path.join( 56 | CWD, "TestOutput", "Output_SolveLargeRoutePair_" + datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")) 57 | os.makedirs(self.scratch_folder) 58 | self.output_gdb = os.path.join(self.scratch_folder, "outputs.gdb") 59 | arcpy.management.CreateFileGDB(os.path.dirname(self.output_gdb), os.path.basename(self.output_gdb)) 60 | 61 | # Copy some data to the output gdb to serve as barriers. Do not use tutorial data directly as input because 62 | # the tests will write network location fields to it, and we don't want to modify the user's original data. 63 | self.barriers = os.path.join(self.output_gdb, "Barriers") 64 | arcpy.management.Copy(os.path.join(self.sf_gdb, "Analysis", "CentralDepots"), self.barriers) 65 | 66 | self.rt_args_one_to_one = { 67 | "origins": self.origins, 68 | "origin_id_field": "ID", 69 | "destinations": self.destinations, 70 | "dest_id_field": "NAME", 71 | "pair_type": PreassignedODPairType.one_to_one, 72 | "network_data_source": self.local_nd, 73 | "travel_mode": self.local_tm_dist, 74 | "time_units": "Minutes", 75 | "distance_units": "Miles", 76 | "chunk_size": 15, 77 | "max_processes": 4, 78 | "output_routes": os.path.join(self.output_gdb, "OutRoutes_OneToOne"), 79 | "assigned_dest_field": "StoreID", 80 | "time_of_day": self.time_of_day_str, 81 | "barriers": "", 82 | "precalculate_network_locations": True, 83 | "sort_origins": True, 84 | "reverse_direction": False 85 | } 86 | self.rt_args_many_to_many = { 87 | "origins": self.origins, 88 | "origin_id_field": "ID", 89 | "destinations": self.destinations, 90 | "dest_id_field": "NAME", 91 | "pair_type": PreassignedODPairType.many_to_many, 92 | "network_data_source": self.local_nd, 93 | "travel_mode": self.local_tm_dist, 94 | "time_units": "Minutes", 95 | "distance_units": "Miles", 96 | "chunk_size": 15, 97 | "max_processes": 4, 98 | "output_routes": os.path.join(self.output_gdb, "OutRoutes_ManyToMany"), 99 | "pair_table": self.od_pairs_table, 100 | "pair_table_origin_id_field": "OriginID", 101 | "pair_table_dest_id_field": "DestinationID", 102 | "time_of_day": self.time_of_day_str, 103 | "barriers": "", 104 | "precalculate_network_locations": True, 105 | "sort_origins": False, 106 | "reverse_direction": False 107 | } 108 | 109 | def test_validate_inputs_one_to_one(self): 110 | """Test the validate_inputs function for all generic/shared cases and one-to-one pair type specific cases.""" 111 | does_not_exist = os.path.join(self.sf_gdb, "Analysis", "DoesNotExist") 112 | invalid_inputs = [ 113 | ("chunk_size", -5, ValueError, "Chunk size must be greater than 0."), 114 | ("max_processes", 0, ValueError, "Maximum allowed parallel processes must be greater than 0."), 115 | ("max_processes", 5000, ValueError, ( 116 | f"The maximum allowed parallel processes cannot exceed {MAX_ALLOWED_MAX_PROCESSES:} due " 117 | "to limitations imposed by Python's concurrent.futures module." 118 | )), 119 | ("time_units", "BadUnits", ValueError, "Invalid time units: BadUnits"), 120 | ("distance_units", "BadUnits", ValueError, "Invalid distance units: BadUnits"), 121 | ("origins", does_not_exist, ValueError, f"Input dataset {does_not_exist} does not exist."), 122 | ("destinations", does_not_exist, ValueError, f"Input dataset {does_not_exist} does not exist."), 123 | ("barriers", [does_not_exist], ValueError, f"Input dataset {does_not_exist} does not exist."), 124 | ("network_data_source", does_not_exist, ValueError, 125 | f"Input network dataset {does_not_exist} does not exist."), 126 | ("travel_mode", "BadTM", ValueError if arcgis_version >= "3.1" else RuntimeError, ""), 127 | ("time_of_day", "3/29/2022 4:45 PM", ValueError, ""), 128 | ("time_of_day", "BadDateTime", ValueError, ""), 129 | ("pair_type", "BadPairType", ValueError, "Invalid preassigned OD pair type: BadPairType"), 130 | ("origin_id_field", "BadField", ValueError, 131 | f"Unique ID field BadField does not exist in dataset {self.origins}."), 132 | ("origin_id_field", "STATE_NAME", ValueError, 133 | f"Non-unique values were found in the unique ID field STATE_NAME in {self.origins}."), 134 | ("dest_id_field", "BadField", ValueError, 135 | f"Unique ID field BadField does not exist in dataset {self.destinations}."), 136 | ("dest_id_field", "ServiceTime", ValueError, 137 | f"Non-unique values were found in the unique ID field ServiceTime in {self.destinations}."), 138 | ("assigned_dest_field", "BadField", ValueError, 139 | f"Assigned destination field BadField does not exist in Origins dataset {self.origins}."), 140 | ("assigned_dest_field", "STATE_NAME", ValueError, 141 | (f"All origins in the Origins dataset {self.origins} have invalid values in the assigned " 142 | f"destination field STATE_NAME that do not correspond to values in the " 143 | f"destinations unique ID field NAME in {self.destinations}. Ensure that you " 144 | "have chosen the correct datasets and fields and that the field types match.")), 145 | ("assigned_dest_field", None, ValueError, 146 | "Assigned destination field is required when preassigned OD pair type is " 147 | f"{PreassignedODPairType.one_to_one.name}") 148 | ] 149 | for invalid_input in invalid_inputs: 150 | property_name, value, error_type, expected_message = invalid_input 151 | with self.subTest( 152 | property_name=property_name, value=value, error_type=error_type, expected_message=expected_message 153 | ): 154 | inputs = deepcopy(self.rt_args_one_to_one) 155 | inputs[property_name] = value 156 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**inputs) 157 | with self.assertRaises(error_type) as ex: 158 | rt_solver._validate_inputs() 159 | if expected_message: 160 | self.assertEqual(expected_message, str(ex.exception)) 161 | 162 | def test_validate_inputs_many_to_many(self): 163 | """Test the validate_inputs function for many-to-many pair type specific cases.""" 164 | does_not_exist = os.path.join(self.sf_gdb, "DoesNotExist") 165 | invalid_inputs = [ 166 | ("pair_table", None, ValueError, 167 | "Origin-destination pair table is required when preassigned OD pair type is " 168 | f"{PreassignedODPairType.many_to_many.name}"), 169 | ("pair_table", does_not_exist, ValueError, f"Input dataset {does_not_exist} does not exist."), 170 | ("pair_table_origin_id_field", None, ValueError, 171 | "Origin-destination pair table Origin ID field is required when preassigned OD pair type is " 172 | f"{PreassignedODPairType.many_to_many.name}"), 173 | ("pair_table_origin_id_field", "BadFieldName", ValueError, 174 | ("Origin-destination pair table Origin ID field BadFieldName does not exist in " 175 | f"{self.rt_args_many_to_many['pair_table']}.")), 176 | ("pair_table_dest_id_field", None, ValueError, 177 | "Origin-destination pair table Destination ID field is required when preassigned OD pair type is " 178 | f"{PreassignedODPairType.many_to_many.name}"), 179 | ("pair_table_dest_id_field", "BadFieldName", ValueError, 180 | ("Origin-destination pair table Destination ID field BadFieldName does not exist in " 181 | f"{self.rt_args_many_to_many['pair_table']}.")) 182 | ] 183 | for invalid_input in invalid_inputs: 184 | property_name, value, error_type, expected_message = invalid_input 185 | with self.subTest( 186 | property_name=property_name, value=value, error_type=error_type, expected_message=expected_message 187 | ): 188 | inputs = deepcopy(self.rt_args_many_to_many) 189 | inputs[property_name] = value 190 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**inputs) 191 | with self.assertRaises(error_type) as ex: 192 | rt_solver._validate_inputs() 193 | if expected_message: 194 | self.assertEqual(expected_message, str(ex.exception)) 195 | 196 | def test_pair_table_errors_no_matching_ods(self): 197 | """Test for correct error when the pair table's origin or destination IDs don't match the input tables.""" 198 | inputs = deepcopy(self.rt_args_many_to_many) 199 | inputs["origin_id_field"] = "OBJECTID" 200 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**inputs) 201 | rt_solver._validate_inputs() 202 | with self.assertRaises(ValueError) as ex: 203 | rt_solver._preprocess_inputs() 204 | self.assertIn( 205 | "All origin-destination pairs in the preassigned origin-destination pair table", 206 | str(ex.exception) 207 | ) 208 | 209 | def test_pair_table_drop_irrelevant_data(self): 210 | """Test that irrelevant data is dropped from the origins, destinations, and pair table.""" 211 | # Construct an OD pair table such that we can check for eliminated duplicate and irrelevant data 212 | in_od_pairs = [ 213 | ["06075013000", "Store_12"], 214 | ["06075013000", "Store_12"], # Duplicate of the last row. Should be eliminated 215 | ["06075013000", "Store_25"], 216 | ["06081602500", "Store_25"], 217 | ["06075030400", "Store_7"], 218 | ["06075030400", "Store_5"], 219 | ] # 3 unique origins; 4 unique destinations; 5 unique OD pairs 220 | od_pair_table = os.path.join("memory", "ODPairsDuplicates") 221 | arcpy.management.CreateTable("memory", "ODPairsDuplicates", template=self.od_pairs_table) 222 | with arcpy.da.InsertCursor( # pylint: disable=no-member 223 | od_pair_table, 224 | [ 225 | self.rt_args_many_to_many["pair_table_origin_id_field"], 226 | self.rt_args_many_to_many["pair_table_dest_id_field"] 227 | ] 228 | ) as cur: 229 | for od_pair in in_od_pairs: 230 | cur.insertRow(od_pair) 231 | 232 | # Validate and preprocess the data 233 | inputs = deepcopy(self.rt_args_many_to_many) 234 | inputs["pair_table"] = od_pair_table 235 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**inputs) 236 | rt_solver._validate_inputs() 237 | rt_solver._preprocess_inputs() 238 | 239 | # Verify that the outputs generated by preprocessing have the correct number of rows 240 | with open(rt_solver.output_pair_table, "r", encoding="utf-8") as f: 241 | output_od_pairs = f.readlines() 242 | self.assertEqual(5, len(output_od_pairs), "Incorrect number of rows in output preprocessed OD pairs CSV.") 243 | self.assertEqual( 244 | 3, int(arcpy.management.GetCount(rt_solver.output_origins).getOutput(0)), 245 | "Incorrect number of rows in output preprocessed origins." 246 | ) 247 | self.assertEqual( 248 | 4, int(arcpy.management.GetCount(rt_solver.output_destinations).getOutput(0)), 249 | "Incorrect number of rows in output preprocessed destinations." 250 | ) 251 | 252 | def test_update_max_inputs_for_service(self): 253 | """Test the update_max_inputs_for_service function.""" 254 | max_routes = 20000000 255 | inputs = deepcopy(self.rt_args_one_to_one) 256 | inputs["network_data_source"] = self.portal_nd 257 | inputs["travel_mode"] = self.portal_tm 258 | inputs["chunk_size"] = max_routes 259 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**inputs) 260 | tool_limits = { 261 | 'forceHierarchyBeyondDistance': 50.0, 262 | 'forceHierarchyBeyondDistanceUnits': 'Miles', 263 | 'maximumFeaturesAffectedByLineBarriers': 500.0, 264 | 'maximumFeaturesAffectedByPointBarriers': 250.0, 265 | 'maximumFeaturesAffectedByPolygonBarriers': 2000.0, 266 | 'maximumGeodesicDistanceUnitsWhenWalking': 'Miles', 267 | 'maximumGeodesicDistanceWhenWalking': 27.0, 268 | 'maximumStops': 10000.0, 269 | 'maximumStopsPerRoute': 150.0 270 | } 271 | rt_solver.service_limits = tool_limits 272 | rt_solver._update_max_inputs_for_service() 273 | self.assertEqual(tool_limits["maximumStops"] / 2, rt_solver.chunk_size) 274 | 275 | # Test when there are no limits 276 | tool_limits = { 277 | 'forceHierarchyBeyondDistance': 50.0, 278 | 'forceHierarchyBeyondDistanceUnits': 'Miles', 279 | 'maximumFeaturesAffectedByLineBarriers': 500.0, 280 | 'maximumFeaturesAffectedByPointBarriers': 250.0, 281 | 'maximumFeaturesAffectedByPolygonBarriers': 2000.0, 282 | 'maximumGeodesicDistanceUnitsWhenWalking': 'Miles', 283 | 'maximumGeodesicDistanceWhenWalking': 27.0, 284 | 'maximumStops': None, 285 | 'maximumStopsPerRoute': 150.0 286 | } 287 | rt_solver.chunk_size = max_routes 288 | rt_solver.service_limits = tool_limits 289 | rt_solver._update_max_inputs_for_service() 290 | self.assertEqual(max_routes, rt_solver.chunk_size) 291 | 292 | def test_solve_large_route_pair_analysis_one_to_one(self): 293 | """Test the full solve route pair workflow for the one-to-one pair type.""" 294 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**self.rt_args_one_to_one) 295 | rt_solver.solve_large_route_pair_analysis() 296 | self.assertTrue(arcpy.Exists(self.rt_args_one_to_one["output_routes"])) 297 | 298 | def test_solve_large_route_pair_analysis_many_to_many(self): 299 | """Test the full solve route pair workflow for the many-to-many pair type.""" 300 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**self.rt_args_many_to_many) 301 | rt_solver.solve_large_route_pair_analysis() 302 | self.assertTrue(arcpy.Exists(self.rt_args_many_to_many["output_routes"])) 303 | 304 | def test_solve_large_route_pair_analysis_use_oids(self): 305 | """Test the full solve route pair workflow for the one-to-one pair type using ObjectID fields as unique IDs.""" 306 | inputs = deepcopy(self.rt_args_one_to_one) 307 | inputs["origin_id_field"] = arcpy.Describe(inputs["origins"]).oidFieldName 308 | dest_oid = arcpy.Describe(inputs["destinations"]).oidFieldName 309 | inputs["dest_id_field"] = dest_oid 310 | inputs["assigned_dest_field"] = dest_oid 311 | rt_solver = solve_large_route_pair_analysis.RoutePairSolver(**inputs) 312 | rt_solver.solve_large_route_pair_analysis() 313 | self.assertTrue(arcpy.Exists(inputs["output_routes"])) 314 | 315 | def test_cli_one_to_one(self): 316 | """Test the command line interface of solve_large_route_pair_analysis for the one-to-one pair type.""" 317 | out_folder = os.path.join(self.scratch_folder, "CLI_CSV_Output_OneToOne") 318 | os.mkdir(out_folder) 319 | out_routes = os.path.join(self.output_gdb, "OutCLIRoutes_OneToOne") 320 | rt_inputs = [ 321 | os.path.join(sys.exec_prefix, "python.exe"), 322 | os.path.join(os.path.dirname(CWD), "solve_large_route_pair_analysis.py"), 323 | "--pair-type", "one_to_one", 324 | "--origins", self.origins, 325 | "--origins-id-field", "ID", 326 | "--destinations", self.destinations, 327 | "--destinations-id-field", "NAME", 328 | "--network-data-source", self.local_nd, 329 | "--travel-mode", self.local_tm_dist, 330 | "--time-units", "Minutes", 331 | "--distance-units", "Miles", 332 | "--max-routes", "15", 333 | "--max-processes", "4", 334 | "--out-routes", out_routes, 335 | "--assigned-dest-field", "StoreID", 336 | "--time-of-day", self.time_of_day_str, 337 | "--precalculate-network-locations", "true", 338 | "--sort-origins", "true", 339 | "--reverse-direction", "false" 340 | ] 341 | result = subprocess.run(rt_inputs, check=True) 342 | self.assertEqual(result.returncode, 0) 343 | self.assertTrue(arcpy.Exists(out_routes)) 344 | 345 | def test_cli_many_to_many(self): 346 | """Test the command line interface of solve_large_route_pair_analysis for the many-to-many pair type.""" 347 | out_folder = os.path.join(self.scratch_folder, "CLI_CSV_Output_ManyToMany") 348 | os.mkdir(out_folder) 349 | out_routes = os.path.join(self.output_gdb, "OutCLIRoutes_ManyToMany") 350 | rt_inputs = [ 351 | os.path.join(sys.exec_prefix, "python.exe"), 352 | os.path.join(os.path.dirname(CWD), "solve_large_route_pair_analysis.py"), 353 | "--pair-type", "many_to_many", 354 | "--origins", self.origins, 355 | "--origins-id-field", "ID", 356 | "--destinations", self.destinations, 357 | "--destinations-id-field", "NAME", 358 | "--network-data-source", self.local_nd, 359 | "--travel-mode", self.local_tm_dist, 360 | "--time-units", "Minutes", 361 | "--distance-units", "Miles", 362 | "--max-routes", "15", 363 | "--max-processes", "4", 364 | "--out-routes", out_routes, 365 | "--od-pair-table", self.od_pairs_table, 366 | "--od-pair-table-origin-id", "OriginID", 367 | "--od-pair-table-dest-id", "DestinationID", 368 | "--time-of-day", self.time_of_day_str, 369 | "--precalculate-network-locations", "true", 370 | "--sort-origins", "false", 371 | "--reverse-direction", "false" 372 | ] 373 | result = subprocess.run(rt_inputs, check=True) 374 | self.assertEqual(result.returncode, 0) 375 | self.assertTrue(arcpy.Exists(out_routes)) 376 | 377 | 378 | if __name__ == '__main__': 379 | unittest.main() 380 | -------------------------------------------------------------------------------- /unittests/unittests_README.txt: -------------------------------------------------------------------------------- 1 | The unit tests use data and from the SanFrancisco.gdb geodatabase from the ArcGIS Pro Network Analyst tutorial data. Download the data from https://links.esri.com/NetworkAnalyst/TutorialData/Pro. Extract the zip file and put SanFrancisco.gdb here in a folder called "TestInput". 2 | 3 | The tests also require valid values for a test portal with network analysis services in the portal_credentials.py file. --------------------------------------------------------------------------------