Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions openrouteservice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Geocode addresses, generate drive-time catchment polygons, and calculate road-ne

Create a free OpenRouteService API key at the [OpenRouteService developer portal](https://openrouteservice.org/dev/#/signup), then add it as the integration connection's **API Key**. The key is passed only in the `Authorization` header; it is never added to request URLs or returned in action output.

Typical catchment workflow: call `geocode_address` for a place in New Zealand, confirm the match when `is_low_confidence` is true, then pass the returned coordinates to `get_isochrone` with the drive-time bands you need. `get_travel_time_matrix` is a labelled origin–destination table only; it does not select facilities or apply demographic rules.
Typical catchment workflow: call `geocode_address` for a place in New Zealand, confirm the match when `is_low_confidence` is true, then pass the returned coordinates to `get_isochrone` with `export_geojson: true` and `include_geojson: false`. Pass the returned file into the next action or script and read `bands` instead of coordinates. `get_travel_time_matrix` is a labelled origin–destination table only; it does not select facilities or apply demographic rules.

## Actions

Expand Down Expand Up @@ -36,14 +36,18 @@ Generates one or more drive-time bands in a single request through the current H
- `time_minutes` (integer array, required) — one to ten driving-time bands in whole minutes from 1 to 60, for example `[5, 10, 15, 30]`. Catchment workflows typically send one to five bands in one call. OpenRouteService rejects longer driving ranges and more than 10 intervals.
- `travel_mode` (optional) — v1 supports `driving-car` only.
- `export_geojson` (optional, default false) — if true, also return the FeatureCollection as a platform file object.
- `include_geojson` (optional, default true) — if false, omit inline `geojson` and return only the exported file plus `bands`. Requires `export_geojson=true`. Use false whenever another action or script will read the file; unsimplified polygons can be hundreds of thousands of tokens.

Catchment pattern: `export_geojson=true` and `include_geojson=false`. `include_geojson=false` without export is `invalid_request` (`field: include_geojson`) and does not call the provider. If file export fails, the action still returns inline `geojson` and sets `message`.

**Outputs**

- `geojson` — a GeoJSON FeatureCollection. The request sets HeiGIT `smoothing` to `0` so polygons are not generalised. Each feature includes a stable `time_minutes` property. Features are sorted in ascending time-band order. The integration parses a JSON-string response when the provider labels it `application/geo+json`.
- `geojson` — a GeoJSON FeatureCollection. The request sets HeiGIT `smoothing` to `0` so polygons are not generalised. Each feature includes a stable `time_minutes` property. Features are sorted in ascending time-band order. The integration parses a JSON-string response when the provider labels it `application/geo+json`. Null when `include_geojson` is false and the file export succeeded.
- `bands` — one compact summary per feature, ascending by `time_minutes`, with no coordinates: `time_minutes`, `geometry_type`, `polygon_count`, `vertex_count`, approximate geodesic `area_sq_km` (holes subtracted; null if the calculation fails), and `bbox` as `[west, south, east, north]` (null if any vertex is unusable; planar WGS84, no antimeridian wrap).
- `provider_metadata` — unaltered provider metadata when present.
- `profile` and `time_minutes` — the routing profile and requested bands (deduplicated, ascending).
- `attribution`, `engine_version`, `build_date`, `graph_date`, `osm_date` — copied from provider metadata when supplied.
- `files` — empty unless `export_geojson` is true. The SDK has no separate artifact API. Files go on `ActionResult.data["files"]` as `{name, contentType, content}` (standard base64), the same Autohive platform channel as Gmail and doc-maker. Autohive materialises that as a tool-output path such as `/tool-outputs/isochrones.geojson`; agents see the path, not the base64. The file never includes the API key.
- `files` — empty unless `export_geojson` is true and serialization succeeds. The SDK has no separate artifact API. Files go on `ActionResult.data["files"]` as `{name, contentType, content}` (standard base64), the same Autohive platform channel as Gmail and doc-maker. Autohive materialises that as a tool-output path such as `/tool-outputs/isochrones.geojson`; agents see the path, not the base64. The file never includes the API key.

### `get_travel_time_matrix`

Expand Down
24 changes: 20 additions & 4 deletions openrouteservice/config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openrouteservice",
"version": "1.2.0",
"version": "1.3.0",
"display_name": "OpenRouteService",
"description": "Geocode addresses, generate drive-time isochrones, and calculate road-network travel-time matrices with OpenRouteService.",
"entry_point": "openrouteservice.py",
Expand Down Expand Up @@ -63,15 +63,16 @@
},
"get_isochrone": {
"display_name": "Get Isochrone",
"description": "Generate exact OpenRouteService GeoJSON drive-time polygons for a point and one or more minute bands. Features include a stable time_minutes property, are returned in ascending band order, and are not simplified. Optionally export the FeatureCollection as a platform file.",
"description": "Generate exact OpenRouteService GeoJSON drive-time polygons for a point and one or more minute bands. Features include a stable time_minutes property, are returned in ascending band order, and are not simplified. Optionally export the FeatureCollection as a platform file. For catchment workflows, set export_geojson=true and include_geojson=false.",
"input_schema": {
"type": "object",
"properties": {
"latitude": {"type": "number", "minimum": -90, "maximum": 90},
"longitude": {"type": "number", "minimum": -180, "maximum": 180},
"travel_mode": {"type": "string", "description": "Routing profile. v1 supports driving-car.", "enum": ["driving-car"], "default": "driving-car"},
"time_minutes": {"type": "array", "description": "One to ten driving-time bands in whole minutes (1-60), for example [5, 10, 15, 30]. Catchment workflows typically send one to five bands; the public API limit is 10.", "items": {"type": "integer", "minimum": 1, "maximum": 60}, "minItems": 1, "maxItems": 10},
"export_geojson": {"type": "boolean", "default": false, "description": "If true, return the FeatureCollection as a platform file object in files (name, contentType, base64 content). Default false."}
"export_geojson": {"type": "boolean", "default": false, "description": "If true, return the FeatureCollection as a platform file object in files (name, contentType, base64 content). Default false."},
"include_geojson": {"type": "boolean", "default": true, "description": "If false, omit the inline geojson and return only the exported file plus the bands summary. Requires export_geojson=true. Unsimplified polygons can be hundreds of thousands of tokens, so use false whenever another action or script will read the file. Default true."}
},
"required": ["latitude", "longitude", "time_minutes"]
},
Expand All @@ -81,7 +82,22 @@
"result": {"type": "boolean"},
"profile": {"type": ["string", "null"]},
"time_minutes": {"type": "array", "items": {"type": "integer"}},
"geojson": {"type": ["object", "null"], "description": "GeoJSON FeatureCollection. Geometry is unsmoothed; each feature has time_minutes. Bands are sorted ascending."},
"geojson": {"type": ["object", "null"], "description": "GeoJSON FeatureCollection. Geometry is unsmoothed; each feature has time_minutes. Bands are sorted ascending. Null when include_geojson is false and the file export succeeded."},
"bands": {
"type": "array",
"description": "One compact summary per isochrone feature, in ascending time_minutes order. No coordinates.",
"items": {
"type": "object",
"properties": {
"time_minutes": {"type": ["integer", "null"]},
"geometry_type": {"type": ["string", "null"]},
"polygon_count": {"type": "integer"},
"vertex_count": {"type": "integer"},
"area_sq_km": {"type": ["number", "null"], "description": "Approximate geodesic area in square kilometres, holes subtracted. Null if the calculation fails."},
"bbox": {"type": ["array", "null"], "description": "[west, south, east, north] in WGS84.", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}
}
}
},
"provider_metadata": {"type": ["object", "null"], "description": "Unmodified metadata returned by OpenRouteService, when supplied."},
"attribution": {"type": ["string", "null"]},
"engine_version": {"type": ["string", "null"]},
Expand Down
179 changes: 176 additions & 3 deletions openrouteservice/openrouteservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class ProviderResponseError(Exception):
MATRIX_MAX_PAIRS = 10000
_BATCHED_METADATA_OMIT = frozenset({"query", "timestamp", "id"})
_POLYGON_TYPES = {"Polygon", "MultiPolygon"}
# Turf.js / WGS84 equatorial radius used by spherical-excess ring area.
_EARTH_RADIUS_M = 6378137.0
_RETRY_SAFE_ERRORS = {"rate_limit", "request_failed"}
_ERROR_RECOVERY = {
"rate_limit": "Wait retry_after_seconds, then retry the same request.",
Expand All @@ -58,6 +60,7 @@ class ProviderResponseError(Exception):
"time_minutes": "Correct the coordinates or time bands, then send a new request.",
"origins": "Correct the origin and destination coordinates, then send a new request.",
"destinations": "Correct the origin and destination coordinates, then send a new request.",
"include_geojson": "Set export_geojson=true when include_geojson=false, or omit include_geojson.",
}
_SNAP_KEYS = ("snapped_latitude", "snapped_longitude", "snapped_distance_metres", "name")

Expand Down Expand Up @@ -385,6 +388,156 @@ def _geojson_export_file(geojson: dict[str, Any]) -> dict[str, str] | None:
return None


def _ring_area_m2(ring: Any) -> float | None:
"""Spherical-excess ring area (Turf ringArea). Positive or negative by winding."""
if not isinstance(ring, list) or len(ring) < 3:
return None
coords: list[tuple[float, float]] = []
for point in ring:
if not isinstance(point, (list, tuple)) or len(point) < 2:
return None
longitude = _numeric_coordinate(point[0])
latitude = _numeric_coordinate(point[1])
if longitude is None or latitude is None:
return None
coords.append((longitude, latitude))
count = len(coords)
area = 0.0
for index in range(count):
if index == count - 2:
lower, middle, upper = count - 2, count - 1, 0
elif index == count - 1:
lower, middle, upper = count - 1, 0, 1
else:
lower, middle, upper = index, index + 1, index + 2
p1 = coords[lower]
p2 = coords[middle]
p3 = coords[upper]
# Fold into [-180, 180] so a date-line hop is ~2°, not ~358°.
delta_lon = (p3[0] - p1[0] + 180.0) % 360.0 - 180.0
area += math.radians(delta_lon) * math.sin(math.radians(p2[1]))
return area * _EARTH_RADIUS_M * _EARTH_RADIUS_M / 2.0


def _polygon_area_m2(coordinates: Any) -> float | None:
if not isinstance(coordinates, list) or not coordinates:
return None
total = 0.0
for index, ring in enumerate(coordinates):
ring_area = _ring_area_m2(ring)
if ring_area is None:
return None
if index == 0:
total += abs(ring_area)
else:
total -= abs(ring_area)
return total


def _geometry_area_sq_km(geometry: dict[str, Any]) -> float | None:
geom_type = geometry.get("type")
coordinates = geometry.get("coordinates")
try:
if geom_type == "Polygon":
area_m2 = _polygon_area_m2(coordinates)
elif geom_type == "MultiPolygon":
if not isinstance(coordinates, list):
return None
total = 0.0
for polygon in coordinates:
part = _polygon_area_m2(polygon)
if part is None:
return None
total += part
area_m2 = total
else:
return None
except (TypeError, ValueError, OverflowError):
return None
if area_m2 is None or not math.isfinite(area_m2):
return None
return area_m2 / 1_000_000.0


def _iter_rings(geometry: dict[str, Any]) -> list[Any]:
geom_type = geometry.get("type")
coordinates = geometry.get("coordinates")
if geom_type == "Polygon" and isinstance(coordinates, list):
return coordinates
if geom_type == "MultiPolygon" and isinstance(coordinates, list):
rings: list[Any] = []
for polygon in coordinates:
if isinstance(polygon, list):
rings.extend(polygon)
return rings
return []


def _polygon_count(geometry: dict[str, Any]) -> int:
geom_type = geometry.get("type")
coordinates = geometry.get("coordinates")
if geom_type == "Polygon":
return 1 if isinstance(coordinates, list) and coordinates else 0
if geom_type == "MultiPolygon" and isinstance(coordinates, list):
return sum(1 for polygon in coordinates if isinstance(polygon, list))
return 0


def _vertex_count(geometry: dict[str, Any]) -> int:
total = 0
for ring in _iter_rings(geometry):
if isinstance(ring, list):
total += len(ring)
return total


def _geometry_bbox(geometry: dict[str, Any]) -> list[float] | None:
west = south = east = north = None
rings = _iter_rings(geometry)
if not rings:
return None
for ring in rings:
if not isinstance(ring, list):
return None
for point in ring:
if not isinstance(point, (list, tuple)) or len(point) < 2:
return None
longitude = _numeric_coordinate(point[0])
latitude = _numeric_coordinate(point[1])
if longitude is None or latitude is None or not math.isfinite(longitude) or not math.isfinite(latitude):
return None
west = longitude if west is None else min(west, longitude)
east = longitude if east is None else max(east, longitude)
south = latitude if south is None else min(south, latitude)
north = latitude if north is None else max(north, latitude)
if west is None or south is None or east is None or north is None:
return None
return [west, south, east, north]


def _isochrone_bands(geojson: dict[str, Any]) -> list[dict[str, Any]]:
"""Compact per-feature summary with no coordinates."""
bands: list[dict[str, Any]] = []
for feature in geojson.get("features") or []:
if not isinstance(feature, dict):
continue
geometry = feature.get("geometry")
if not isinstance(geometry, dict):
geometry = {}
properties = _as_dict(feature.get("properties"))
bands.append(
{
"time_minutes": properties.get("time_minutes"),
"geometry_type": geometry.get("type"),
"polygon_count": _polygon_count(geometry),
"vertex_count": _vertex_count(geometry),
"area_sq_km": _geometry_area_sq_km(geometry),
"bbox": _geometry_bbox(geometry),
}
)
return bands


def _finite_or_none(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
Expand Down Expand Up @@ -753,6 +906,19 @@ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> Ac
time_minutes: list[int] = sorted(set(inputs["time_minutes"]))
if not time_minutes:
raise ValueError("At least one time value is required.")
include_geojson = inputs.get("include_geojson", True)
export_geojson = bool(inputs.get("export_geojson"))
if include_geojson is False and not export_geojson:
return ActionResult(
data=_error_payload(
"invalid_request",
"include_geojson=false requires export_geojson=true.",
field="include_geojson",
retry_safe=False,
recovery=_INVALID_REQUEST_RECOVERY["include_geojson"],
),
cost_usd=0.0,
)

payload = {
"locations": [[inputs["longitude"], inputs["latitude"]]],
Expand Down Expand Up @@ -786,16 +952,23 @@ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> Ac
normalized = _normalize_isochrone_geojson(geojson, time_minutes)
engine = _engine_fields(normalized.get("metadata"))
files: list[dict[str, str]] = []
if inputs.get("export_geojson"):
message = None
inline_geojson: dict[str, Any] | None = normalized
if export_geojson:
exported = _geojson_export_file(normalized)
if exported is not None:
files.append(exported)
if include_geojson is False:
inline_geojson = None
elif include_geojson is False:
message = "GeoJSON file export failed, so the unsimplified geometry is returned inline in geojson."
return ActionResult(
data={
"result": True,
"profile": profile,
"time_minutes": time_minutes,
"geojson": normalized,
"geojson": inline_geojson,
"bands": _isochrone_bands(normalized),
"provider_metadata": normalized.get("metadata"),
"attribution": engine["attribution"],
"engine_version": engine["engine_version"],
Expand All @@ -806,7 +979,7 @@ async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> Ac
"error_type": None,
"error_code": None,
"retry_after_seconds": None,
"message": None,
"message": message,
"field": None,
"valid_alternatives": None,
"recovery": None,
Expand Down
1 change: 1 addition & 0 deletions openrouteservice/tests/teamcity.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENROUTESERVICE_API_KEY=ah:secret
4 changes: 4 additions & 0 deletions openrouteservice/tests/test_openrouteservice_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ async def test_isochrone_response_shape(self, live_context):
bands = [feature["properties"].get("time_minutes") for feature in data["geojson"]["features"]]
assert all(isinstance(band, int) for band in bands)
assert bands == sorted(bands)
assert [band["time_minutes"] for band in data["bands"]] == bands
for feature in data["geojson"]["features"]:
assert feature["geometry"]["type"] in {"Polygon", "MultiPolygon"}
assert "attribution" in data
Expand All @@ -151,10 +152,13 @@ async def test_export_geojson_is_a_platform_file_without_credentials(self, live_
"longitude": AUCKLAND_LONGITUDE,
"time_minutes": [5],
"export_geojson": True,
"include_geojson": False,
},
live_context,
)
data = _require_provider_success(result)
assert data["geojson"] is None
assert data["bands"][0]["time_minutes"] == 5
files = data["files"]
assert len(files) == 1
file_obj = files[0]
Expand Down
Loading
Loading