From 67888e46a58fe85750223d05fd95d727d313c3e6 Mon Sep 17 00:00:00 2001 From: Andre Lepardo Date: Fri, 16 Oct 2026 08:45:10 +1300 Subject: [PATCH 1/4] feat(openrouteservice): add file-only isochrones and bands summary --- openrouteservice/README.md | 10 +- openrouteservice/config.json | 24 ++- openrouteservice/openrouteservice.py | 174 +++++++++++++++++- .../test_openrouteservice_integration.py | 4 + .../tests/test_openrouteservice_unit.py | 146 +++++++++++++++ 5 files changed, 348 insertions(+), 10 deletions(-) diff --git a/openrouteservice/README.md b/openrouteservice/README.md index 1dbb4007..11d54ef9 100644 --- a/openrouteservice/README.md +++ b/openrouteservice/README.md @@ -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 @@ -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]`. - `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` diff --git a/openrouteservice/config.json b/openrouteservice/config.json index 05dce786..f8313dff 100644 --- a/openrouteservice/config.json +++ b/openrouteservice/config.json @@ -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", @@ -63,7 +63,7 @@ }, "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": { @@ -71,7 +71,8 @@ "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"] }, @@ -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"]}, diff --git a/openrouteservice/openrouteservice.py b/openrouteservice/openrouteservice.py index 1d6d06e4..2e480e8f 100644 --- a/openrouteservice/openrouteservice.py +++ b/openrouteservice/openrouteservice.py @@ -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.", @@ -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") @@ -385,6 +388,151 @@ 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] + area += math.radians(p3[0] - p1[0]) * 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 + for ring in _iter_rings(geometry): + if not isinstance(ring, list): + continue + for point in ring: + if not isinstance(point, (list, tuple)) or len(point) < 2: + continue + longitude = _numeric_coordinate(point[0]) + latitude = _numeric_coordinate(point[1]) + if longitude is None or latitude is None: + continue + 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 @@ -753,6 +901,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"]]], @@ -786,16 +947,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"], @@ -806,7 +974,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, diff --git a/openrouteservice/tests/test_openrouteservice_integration.py b/openrouteservice/tests/test_openrouteservice_integration.py index 6977218c..a6381ef0 100644 --- a/openrouteservice/tests/test_openrouteservice_integration.py +++ b/openrouteservice/tests/test_openrouteservice_integration.py @@ -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 @@ -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] diff --git a/openrouteservice/tests/test_openrouteservice_unit.py b/openrouteservice/tests/test_openrouteservice_unit.py index 522d99d0..c276b7fa 100644 --- a/openrouteservice/tests/test_openrouteservice_unit.py +++ b/openrouteservice/tests/test_openrouteservice_unit.py @@ -2,11 +2,14 @@ import base64 import json +import math +from pathlib import Path import aiohttp import pytest from autohive_integrations_sdk import FetchResponse, HTTPError, RateLimitError from autohive_integrations_sdk.integration import ResultType +from jsonschema import Draft7Validator from openrouteservice.openrouteservice import ( GEOCODE_URL, @@ -17,6 +20,7 @@ _geojson_export_file, _match, _matrix_route_batches, + _normalize_isochrone_geojson, _provider_error, openrouteservice, ) @@ -86,6 +90,34 @@ def _isochrone_collection(*minute_bands, metadata=None): ISOCHRONE_INPUTS = {"latitude": -36.8485, "longitude": 174.7633, "time_minutes": [10]} +_ISOCHRONE_OUTPUT_SCHEMA = json.loads( + Path(__file__).resolve().parents[1].joinpath("config.json").read_text(encoding="utf-8") +)["actions"]["get_isochrone"]["output_schema"] + + +def _assert_valid_isochrone_output(data): + Draft7Validator(_ISOCHRONE_OUTPUT_SCHEMA).validate(data) + + +def _closed_ring(west, south, east, north): + return [[west, south], [east, south], [east, north], [west, north], [west, south]] + + +def _square_around_km(lat, lon, side_km): + """Axis-aligned lon/lat square of roughly side_km × side_km.""" + dlat = side_km / 111.32 + dlon = side_km / (111.32 * math.cos(math.radians(abs(lat)))) + half_lat = dlat / 2 + half_lon = dlon / 2 + return _closed_ring(lon - half_lon, lat - half_lat, lon + half_lon, lat + half_lat) + + +def _feature_for_geometry(minutes, geometry): + return { + "type": "Feature", + "properties": {"group_index": 0, "value": minutes * 60}, + "geometry": geometry, + } def _action_data(result): @@ -535,6 +567,120 @@ def boom(_geojson): assert "api key" not in (data.get("message") or "").lower() assert "api key" not in (data.get("recovery") or "").lower() + async def test_file_only_omits_inline_geojson_and_returns_bands(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data=ISOCHRONE_RESPONSE) + result = await openrouteservice.execute_action( + "get_isochrone", + {**ISOCHRONE_INPUTS, "export_geojson": True, "include_geojson": False}, + mock_context, + ) + data = _action_data(result) + _assert_valid_isochrone_output(data) + assert data["result"] is True + assert data["geojson"] is None + assert len(data["files"]) == 1 + exported = json.loads(base64.b64decode(data["files"][0]["content"])) + expected = _normalize_isochrone_geojson(ISOCHRONE_RESPONSE, [10]) + assert exported == expected + assert len(data["bands"]) == 1 + band = data["bands"][0] + assert band["time_minutes"] == 10 + assert band["geometry_type"] == "Polygon" + assert band["polygon_count"] == 1 + assert band["vertex_count"] == 3 + assert band["bbox"] == [174.76, -36.84, 174.77, -36.84] + assert "coordinates" not in band + + @pytest.mark.parametrize("export_geojson", [False, None]) + async def test_include_geojson_false_without_export_is_invalid_request(self, mock_context, export_geojson): + inputs = {**ISOCHRONE_INPUTS, "include_geojson": False} + if export_geojson is not None: + inputs["export_geojson"] = export_geojson + result = await openrouteservice.execute_action("get_isochrone", inputs, mock_context) + data = _action_data(result) + _assert_valid_isochrone_output(data) + assert data["result"] is False + assert data["error_type"] == "invalid_request" + assert data["error_code"] == "invalid_request" + assert data["field"] == "include_geojson" + assert data["retry_safe"] is False + mock_context.fetch.assert_not_called() + + async def test_file_only_export_failure_keeps_inline_geojson_and_message(self, mock_context, monkeypatch): + import sys + + module = sys.modules["openrouteservice.openrouteservice"] + monkeypatch.setattr(module, "_geojson_export_file", lambda _geojson: None) + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data=ISOCHRONE_RESPONSE) + result = await openrouteservice.execute_action( + "get_isochrone", + {**ISOCHRONE_INPUTS, "export_geojson": True, "include_geojson": False}, + mock_context, + ) + data = _action_data(result) + _assert_valid_isochrone_output(data) + assert data["result"] is True + assert data["geojson"]["features"] + assert data["files"] == [] + assert data["message"] + + async def test_default_call_keeps_inline_geojson_and_adds_bands(self, mock_context): + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data=ISOCHRONE_RESPONSE) + result = await openrouteservice.execute_action("get_isochrone", ISOCHRONE_INPUTS, mock_context) + data = _action_data(result) + _assert_valid_isochrone_output(data) + assert data["geojson"]["features"][0]["geometry"] == ISOCHRONE_POLYGON + assert data["geojson"]["features"][0]["properties"]["time_minutes"] == 10 + assert data["files"] == [] + assert [band["time_minutes"] for band in data["bands"]] == [10] + + async def test_bands_count_multipolygon_parts_and_subtract_holes(self, mock_context): + outer = _square_around_km(-41.0, 174.76, 2.0) + hole = _square_around_km(-41.0, 174.76, 1.0) + second = _square_around_km(-41.05, 174.80, 1.0) + payload = { + "type": "FeatureCollection", + "features": [ + _feature_for_geometry(15, {"type": "Polygon", "coordinates": [outer, hole]}), + _feature_for_geometry(30, {"type": "MultiPolygon", "coordinates": [[outer], [second]]}), + ], + "metadata": ISOCHRONE_RESPONSE["metadata"], + } + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data=payload) + result = await openrouteservice.execute_action( + "get_isochrone", + {"latitude": -41.0, "longitude": 174.76, "time_minutes": [15, 30]}, + mock_context, + ) + data = _action_data(result) + _assert_valid_isochrone_output(data) + by_minutes = {band["time_minutes"]: band for band in data["bands"]} + hole_band = by_minutes[15] + multi_band = by_minutes[30] + assert hole_band["polygon_count"] == 1 + assert hole_band["vertex_count"] == 10 + assert hole_band["area_sq_km"] == pytest.approx(3.0, rel=0.05) + assert multi_band["geometry_type"] == "MultiPolygon" + assert multi_band["polygon_count"] == 2 + assert multi_band["vertex_count"] == 10 + + async def test_band_area_of_one_square_kilometre_near_41s(self, mock_context): + ring = _square_around_km(-41.0, 174.76, 1.0) + payload = { + "type": "FeatureCollection", + "features": [_feature_for_geometry(10, {"type": "Polygon", "coordinates": [ring]})], + "metadata": ISOCHRONE_RESPONSE["metadata"], + } + mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data=payload) + result = await openrouteservice.execute_action( + "get_isochrone", + {"latitude": -41.0, "longitude": 174.76, "time_minutes": [10]}, + mock_context, + ) + data = _action_data(result) + _assert_valid_isochrone_output(data) + assert data["bands"][0]["area_sq_km"] == pytest.approx(1.0, abs=0.02) + def test_geojson_export_skips_non_finite_values(self): payload = {"type": "FeatureCollection", "features": [{"value": float("nan")}]} assert _geojson_export_file(payload) is None From 7460c98b90593fff7012dcc63157e79929049484 Mon Sep 17 00:00:00 2001 From: Andre Lepardo Date: Fri, 16 Oct 2026 09:17:54 +1300 Subject: [PATCH 2/4] fix(openrouteservice): null band bbox when a vertex is unusable --- openrouteservice/README.md | 2 +- openrouteservice/openrouteservice.py | 13 ++++++++----- .../tests/test_openrouteservice_unit.py | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/openrouteservice/README.md b/openrouteservice/README.md index 11d54ef9..3d910fb7 100644 --- a/openrouteservice/README.md +++ b/openrouteservice/README.md @@ -43,7 +43,7 @@ Catchment pattern: `export_geojson=true` and `include_geojson=false`. `include_g **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`. 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]`. +- `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. diff --git a/openrouteservice/openrouteservice.py b/openrouteservice/openrouteservice.py index 2e480e8f..506c83c0 100644 --- a/openrouteservice/openrouteservice.py +++ b/openrouteservice/openrouteservice.py @@ -491,16 +491,19 @@ def _vertex_count(geometry: dict[str, Any]) -> int: def _geometry_bbox(geometry: dict[str, Any]) -> list[float] | None: west = south = east = north = None - for ring in _iter_rings(geometry): + rings = _iter_rings(geometry) + if not rings: + return None + for ring in rings: if not isinstance(ring, list): - continue + return None for point in ring: if not isinstance(point, (list, tuple)) or len(point) < 2: - continue + return None longitude = _numeric_coordinate(point[0]) latitude = _numeric_coordinate(point[1]) - if longitude is None or latitude is None: - continue + 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) diff --git a/openrouteservice/tests/test_openrouteservice_unit.py b/openrouteservice/tests/test_openrouteservice_unit.py index c276b7fa..da4b2a98 100644 --- a/openrouteservice/tests/test_openrouteservice_unit.py +++ b/openrouteservice/tests/test_openrouteservice_unit.py @@ -18,6 +18,7 @@ MATRIX_TIMEOUT_SECONDS, MissingApiKeyError, _geojson_export_file, + _geometry_bbox, _match, _matrix_route_batches, _normalize_isochrone_geojson, @@ -622,7 +623,9 @@ async def test_file_only_export_failure_keeps_inline_geojson_and_message(self, m assert data["result"] is True assert data["geojson"]["features"] assert data["files"] == [] - assert data["message"] + assert data["message"] == ( + "GeoJSON file export failed, so the unsimplified geometry is returned inline in geojson." + ) async def test_default_call_keeps_inline_geojson_and_adds_bands(self, mock_context): mock_context.fetch.return_value = FetchResponse(status=200, headers={}, data=ISOCHRONE_RESPONSE) @@ -681,6 +684,18 @@ async def test_band_area_of_one_square_kilometre_near_41s(self, mock_context): _assert_valid_isochrone_output(data) assert data["bands"][0]["area_sq_km"] == pytest.approx(1.0, abs=0.02) + def test_band_bbox_is_null_when_a_vertex_is_unusable(self): + poisoned = { + "type": "Polygon", + "coordinates": [[[174.76, -36.84], [float("nan"), -36.84], [174.77, -36.85], [174.76, -36.84]]], + } + truncated = { + "type": "Polygon", + "coordinates": [[[174.76, -36.84], [174.77], [174.77, -36.85], [174.76, -36.84]]], + } + assert _geometry_bbox(poisoned) is None + assert _geometry_bbox(truncated) is None + def test_geojson_export_skips_non_finite_values(self): payload = {"type": "FeatureCollection", "features": [{"value": float("nan")}]} assert _geojson_export_file(payload) is None From faa7ea9246829ab4a30b86e081399486574216e1 Mon Sep 17 00:00:00 2001 From: Andre Lepardo Date: Fri, 16 Oct 2026 10:14:31 +1300 Subject: [PATCH 3/4] test(openrouteservice): load live API key from TeamCity SSM --- openrouteservice/tests/teamcity.env | 1 + 1 file changed, 1 insertion(+) create mode 100644 openrouteservice/tests/teamcity.env diff --git a/openrouteservice/tests/teamcity.env b/openrouteservice/tests/teamcity.env new file mode 100644 index 00000000..9120fab4 --- /dev/null +++ b/openrouteservice/tests/teamcity.env @@ -0,0 +1 @@ +OPENROUTESERVICE_API_KEY=ah:secret From 92c88f4d7e1e985a206bb52a0c07fd41b63b883c Mon Sep 17 00:00:00 2001 From: Andre Lepardo Date: Fri, 16 Oct 2026 10:32:55 +1300 Subject: [PATCH 4/4] fix(openrouteservice): wrap isochrone band area across the date line --- openrouteservice/openrouteservice.py | 4 +++- .../tests/test_openrouteservice_unit.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/openrouteservice/openrouteservice.py b/openrouteservice/openrouteservice.py index 506c83c0..b32bcf4e 100644 --- a/openrouteservice/openrouteservice.py +++ b/openrouteservice/openrouteservice.py @@ -413,7 +413,9 @@ def _ring_area_m2(ring: Any) -> float | None: p1 = coords[lower] p2 = coords[middle] p3 = coords[upper] - area += math.radians(p3[0] - p1[0]) * math.sin(math.radians(p2[1])) + # 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 diff --git a/openrouteservice/tests/test_openrouteservice_unit.py b/openrouteservice/tests/test_openrouteservice_unit.py index da4b2a98..cdb7f8a1 100644 --- a/openrouteservice/tests/test_openrouteservice_unit.py +++ b/openrouteservice/tests/test_openrouteservice_unit.py @@ -18,6 +18,7 @@ MATRIX_TIMEOUT_SECONDS, MissingApiKeyError, _geojson_export_file, + _geometry_area_sq_km, _geometry_bbox, _match, _matrix_route_batches, @@ -684,6 +685,25 @@ async def test_band_area_of_one_square_kilometre_near_41s(self, mock_context): _assert_valid_isochrone_output(data) assert data["bands"][0]["area_sq_km"] == pytest.approx(1.0, abs=0.02) + def test_band_area_across_antimeridian_matches_same_size_square(self): + def wrap_lon(longitude: float) -> float: + return (longitude + 180.0) % 360.0 - 180.0 + + lat = -44.0 + side_km = 1.0 + dlat = side_km / 111.32 + dlon = side_km / (111.32 * math.cos(math.radians(abs(lat)))) + west = wrap_lon(180.0 - dlon / 2) + east = wrap_lon(180.0 + dlon / 2) + south = lat - dlat / 2 + north = lat + dlat / 2 + dateline = {"type": "Polygon", "coordinates": [_closed_ring(west, south, east, north)]} + mainland = {"type": "Polygon", "coordinates": [_square_around_km(lat, 174.76, side_km)]} + assert west > 0 + assert east < 0 + assert _geometry_area_sq_km(dateline) == pytest.approx(_geometry_area_sq_km(mainland), rel=0.02) + assert _geometry_area_sq_km(dateline) == pytest.approx(1.0, abs=0.02) + def test_band_bbox_is_null_when_a_vertex_is_unusable(self): poisoned = { "type": "Polygon",