Skip to content

Commit

Permalink
Various cleanup and documentation (untested)
Browse files Browse the repository at this point in the history
  • Loading branch information
jessjaco committed Jan 23, 2024
1 parent 1e2603c commit 5fb7e58
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 80 deletions.
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,39 @@ To install, follow instructions in [src/INSTALL.md](src/INSTALL.md).

## Versions

The versions of Circuitscape, Omniscape, and Julia used in this tool are defined in [Project.toml](src/Project.toml)
The versions of Circuitscape, Omniscape, and Julia used in this tool are defined in [Project.toml](src/Project.toml)
and the [packaging workflow](.github/workflows/zip_release.yaml); these will be periodically updated.

Since Arc Pro typically updates automatically, it is difficult to test across different versions. This tool was created
using Arc Pro 3.x series (last tested version: 3.2.1).

To ease usage and maintain compatibility, Julia itself is distributed in the zip package. It is the latest
version (currently 1.10.0) tested with the versions of Circuitscape and Omniscape included with
version (currently 1.10.0) tested with the versions of Circuitscape and Omniscape included with
[Project.toml](src/Project.toml).

## Program Design

All parameters within the tool are loaded as defined in the documentation and/or source code for Circuitscape and Omniscape.
Specifically, I defined a [json schema](https://json-schema.org) for each which are mantained in [a separate
repo](https://github.com/jessjaco/circuitscape-schema) and loaded as a submodule here.
repo](https://github.com/jessjaco/circuitscape-schema) and loaded as a submodule here.

Circuitscape and Omniscape are installed in a siloed environment on first usage
and run via python using `subprocess.Popen`.

## Bugs and Caveats

The limitations of Arc Pro Python script tools as well as Python <-> Julia interprocess communications necessitate
certain limitations. Among these

1. There is no way to cancel running jobs.
Using the 'cancel' option in a running tool is typically ineffective (Arc Pro only cancels a python script between
lines). To cancel a running process, you will need to exit Arc Pro completely (or kill it in task manager).
Using the 'cancel' option in a running tool is typically ineffective (Arc Pro only cancels a python script between
lines). To cancel a running process, you will need to exit Arc Pro completely (or kill it in task manager).
1. Errors do not always show as failed runs in Arc Pro. To be sure, click "View Details".
1. The tool does not respect any environment settings in Arc Pro.

Please report other issues at
https://github.com/jessjaco/circuitscape-arc-pro/issues.

## Acknowledgements

The tool was authored by Jesse Anderson and guided by Kimberly Hall with The Nature Conservancy. Funding for this
Expand Down
12 changes: 7 additions & 5 deletions src/Circuitscape.pyt
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from Run_Circuitscape import Run_Circuitscape
from Run_Omniscape import Run_Omniscape
"""This is the entrypoint (from Arc Pro's perspective) for the tool and
is what is loaded when a user selects "Add Toolbox".
"""
from tools import Run_Circuitscape, Run_Omniscape


class Toolbox(object):
def __init__(self):
"""Define the toolbox (the name of the toolbox is the name of the
.pyt file)."""
.pyt file). It containes two tools."""
self.label = "Circuitscape"
self.alias = "Circuitscape"

# List of tool classes associated with this toolbox
self.tools = [Run_Circuitscape, Run_Omniscape]
self.tools = [Run_Circuitscape, Run_Omniscape]

15 changes: 0 additions & 15 deletions src/Run_Circuitscape.py

This file was deleted.

19 changes: 0 additions & 19 deletions src/Run_Omniscape.py

This file was deleted.

48 changes: 39 additions & 9 deletions src/parameters.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,54 @@
"""This file contains code to convert the schemas for CS and OS into lists
of arcpy parameters to be consumed by the tools. The schemas are loaded more
or less exactly as written. The majority of the code here is to
1) Convert types in the schema to corresponding parameter types
2) Categorize parameters based on where we want them to fall in the GUI. This
could be done in the schema in some way, but I think that would only be
needed if these categories were used somewhere else.
"""
import json
from pathlib import Path

from arcpy import Parameter


def load_circuitscape_schema():
return _load_schema("schema.json")


def load_omniscape_schema():
return _load_schema("omniscape-schema.json")


def _load_schema(file: str) -> dict:
schema_path = Path(__file__).parent / "circuitscape-schema" / file
with schema_path.open() as f:
schema = json.load(f)
return schema


def load_omniscape_parameters(schema: dict) -> list[Parameter]:
schema['properties']['threads'] = dict(name="Number of threads", type="integer", default=1)
"""Categorize OS parameters. This was done similarly to how they are
layed out in the OS documentation."""
schema["properties"]["threads"] = dict(
name="Number of threads", type="integer", default=1
)

categories = {
"General": ["project_name", "resistance_file", "source_file", "radius", "block_size"],
"General": [
"project_name",
"resistance_file",
"source_file",
"radius",
"block_size",
],
"Resistance options": [
"resistance_is_conductance",
"resistance_is_conductance",
"source_from_resistance",
"r_cutoff",
"reclassify_resistance",
"reclass_table",
"write_reclassified_resistance"
"write_reclassified_resistance",
],
"Advanced options": [
"allow_different_projections",
Expand All @@ -46,7 +68,7 @@ def load_omniscape_parameters(schema: dict) -> list[Parameter]:
"calc_normalized_current",
"calc_flow_potential",
"write_raw_currmap",
"write_as_tif"
"write_as_tif",
],
"Conditional options": [
"conditional",
Expand All @@ -61,14 +83,18 @@ def load_omniscape_parameters(schema: dict) -> list[Parameter]:
"condition2_upper",
"compare_to_future",
"condition1_future_file",
"condition2_future_file"
]
"condition2_future_file",
],
}
default_categories = ["General"]
outputs = ["project_name"]
return _load_parameters(schema, categories, default_categories, outputs)


def load_circuitscape_parameters(schema: dict) -> list[Parameter]:
"""Load and categorize CS parameters. The categories were derived from
the documentation but also how they were layed out in the old CS 4 GUI
tool."""
categories = {
"General": ["data_type", "scenario"],
"Resistance options": ["habitat_file", "habitat_map_is_resistances"],
Expand Down Expand Up @@ -105,7 +131,9 @@ def load_circuitscape_parameters(schema: dict) -> list[Parameter]:
return _load_parameters(schema, categories, default_categories, outputs)


def _load_parameters(schema, categories, default_categories, outputs) -> list[Parameter]:
def _load_parameters(
schema, categories, default_categories, outputs
) -> list[Parameter]:
parameters = []
for category, parameter_keys in categories.items():
for parameter_key in parameter_keys:
Expand All @@ -120,6 +148,7 @@ def _load_parameters(schema, categories, default_categories, outputs) -> list[Pa
parameters.append(parameter)
return parameters


def _load_parameter(name: str, info: dict, required: bool, **kwargs) -> Parameter:
p = Parameter(
name=name,
Expand All @@ -135,6 +164,7 @@ def _load_parameter(name: str, info: dict, required: bool, **kwargs) -> Paramete


def _get_type(info: dict) -> str:
"""Set the (most) appropriate parameter type based on the schema types."""
type = None
if info["name"] == "Project name":
type = "DEFolder"
Expand All @@ -151,7 +181,7 @@ def _get_base_type(basetype: str, default: "GPType" = str) -> str:
# Or could be GPLong, but this handles all numbers
number="GPDouble",
string="GPString",
integer="GPLong"
integer="GPLong",
)
return lookup.get(basetype, default)

Expand Down
54 changes: 32 additions & 22 deletions src/runner.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
"""This contains the code to actually run CS or OS. Except for the `messages`
parameter, these are independent of ESRI. Theoretically if you created
some sort of messages object which had `addMessage` and `addErrorMessage`
accessors, you could use this by itself. Another approach would be to allow
messages to be None, and test for that before writing. Another option would be
wrap messages in some sort of logger class and treat it like a standard logger.
"""
from pathlib import Path
from subprocess import Popen, PIPE, STDOUT, CREATE_NO_WINDOW
from typing import Callable


def run_ascape(
get_script: Callable, config_file: Path, command_args: dict, messages
) -> None:
def run_circuitscape(config_file: Path, command_args: dict, messages) -> None:
working_dir = Path(__file__).resolve().parent
wrking_dir = str(working_dir).replace("\\", "/")
cfg_file = str(config_file).replace("\\", "/")
config_file_fixed = str(config_file).replace("\\", "/")
command = f'"using Pkg; Pkg.activate(realpath(\\"{working_dir}\\")); Pkg.instantiate(); using Circuitscape; compute(realpath(\\"{config_file_fixed}\\"))"'
return run_julia_command(command, command_args, messages)


def run_omniscape(config_file: Path, command_args: dict, messages) -> None:
working_dir = Path(__file__).resolve().parent
config_file_fixed = str(config_file).replace("\\", "/")
command = f'"using Pkg; Pkg.activate(realpath(\\"{working_dir}\\")); Pkg.instantiate(); using Omniscape; run_omniscape(realpath(\\"{config_file_fixed}\\"))"'

return run_julia_command(command, command_args, messages)


def run_julia_command(command: str, command_args: dict, messages) -> None:
working_dir = Path(__file__).resolve().parent

# The path to the julia exe itself. This needs to be changed when the
# workflow is changed
julia_command = "julia-1.10.0/bin/julia.exe"

for k, v in command_args.items():
# Only supports full kw args
julia_command += f" --{k} {v}"
julia_exe = working_dir / julia_command
julia_script = get_script(wrking_dir, cfg_file)
full_command = f"{julia_exe} -e {julia_script}"
full_command = f"{julia_exe} -e {command}"
messages.addMessage(full_command)

# this pattern is necessary (bufsize, but also the context) as it's the
# only way I could find to get OS progress messages to propagate to the
# Arc window.
with Popen(
full_command,
stdout=PIPE,
Expand All @@ -30,17 +54,3 @@ def run_ascape(
messages.addErrorMessage(line)
else:
messages.addMessage(line)


def run_omniscape(config_file: Path, messages, command_args) -> None:
julia_script = (
lambda working_dir, config_file: f'"using Pkg; Pkg.activate(realpath(\\"{working_dir}\\")); Pkg.instantiate(); using Omniscape; run_omniscape(realpath(\\"{config_file}\\"))"'
)
return run_ascape(julia_script, config_file, command_args, messages)


def run_circuitscape(config_file: Path, messages, command_args) -> None:
julia_script = (
lambda working_dir, config_file: f'"using Pkg; Pkg.activate(realpath(\\"{working_dir}\\")); Pkg.instantiate(); using Circuitscape; compute(realpath(\\"{config_file}\\"))"'
)
return run_ascape(julia_script, config_file, command_args, messages)
58 changes: 53 additions & 5 deletions src/Run_Tool.py → src/tools.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import json
import os
from pathlib import Path
from typing import Callable, List

from arcpy import Parameter

from parameters import (
load_circuitscape_parameters,
load_circuitscape_schema,
load_omniscape_parameters,
load_omniscape_schema,
)

from runner import run_circuitscape, run_omniscape


class Run_Tool(object):
def __init__(
self,
label: str,
description: str,
runner: Callable,
schema: dict,
initial_params: list[Parameter],
commandArgParameterNames: list = [],
):
"""Define the tool (tool name is the name of the class)."""
"""Base class for both tools (Run Omniscape & Run Circuitscape).
Each follows the same basic flow. First load the parameters and
start the tool. When executed, write a config file and call the runner.
"""
self.label = label
self.description = description
self.runner = runner
self.canRunInBackground = False
self.schema = schema
self.initial_params = initial_params
self.commandArgParameterNames = commandArgParameterNames

def getParameterInfo(self):
Expand All @@ -42,11 +57,11 @@ def updateMessages(self, parameters: List[Parameter]):
return parameters

def isLicensed(self):
"""Set whether tool is licensed to execute."""
"""Always licensed"""
return True

def execute(self, parameters: List[Parameter], messages):
"""The source code of the tool."""
"""Writes the config file and runs CS or OS."""

messages.addMessage("running")
messages.addMessage(os.path.realpath(__file__))
Expand All @@ -59,5 +74,38 @@ def execute(self, parameters: List[Parameter], messages):
elif parameter.value is not None:
dst.write(f"{parameter.name} = {parameter.value}\n")

self.runner(config_file, messages, command_args)
self.runner(config_file, command_args, messages)
return True


class Run_Circuitscape(Run_Tool):
def __init__(self):
"""Define the tool (tool name is the name of the class)."""
label = "Run Circuitscape"
description = ""
schema = load_circuitscape_schema()
initial_params = load_circuitscape_parameters(schema)
super().__init__(
label=label,
description=description,
runner=run_circuitscape,
schema=schema,
initial_params=initial_params,
)


class Run_Omniscape(Run_Tool):
def __init__(self):
"""Define the tool (tool name is the name of the class)."""
label = "Run Omniscape"
description = "Run the Omniscape tool"
schema = load_omniscape_schema()
initial_params = load_omniscape_parameters(schema)
super().__init__(
label=label,
description=description,
runner=run_omniscape,
commandArgParameterNames=["threads"],
schema=schema,
initial_params=initial_params,
)

0 comments on commit 5fb7e58

Please sign in to comment.