Skip to content

Commit a3bb809

Browse files
Introduce PlotsAnalysisPlatformClient
This is a wrapper around Picterra's PlotsAnalysis platform public api.
1 parent c8a8d76 commit a3bb809

File tree

7 files changed

+251
-2
lines changed

7 files changed

+251
-2
lines changed

docs/api.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ DetectorPlatformClient
99
.. autoclass:: picterra.APIClient
1010
:members:
1111

12+
13+
PlotsAnalysisPlatformClient
14+
---------------------------
15+
16+
.. autoclass:: picterra.PlotsAnalysisPlatformClient
17+
:members:
18+
19+
1220
Pagination
1321
----------
1422

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"type": "FeatureCollection",
3+
"features": [
4+
{
5+
"type": "Feature",
6+
"properties": { "id": "PLOT-1"},
7+
"geometry": {
8+
"coordinates": [
9+
[
10+
[
11+
-49.27362953726589,
12+
-2.616445544400051
13+
],
14+
[
15+
-49.28121040069351,
16+
-2.6284004278355297
17+
],
18+
[
19+
-49.271919906343896,
20+
-2.627524039887618
21+
],
22+
[
23+
-49.26726341159804,
24+
-2.6196814639718013
25+
],
26+
[
27+
-49.27362953726589,
28+
-2.616445544400051
29+
]
30+
]
31+
],
32+
"type": "Polygon"
33+
}
34+
},
35+
{
36+
"type": "Feature",
37+
"properties": { "id": "PLOT-2"},
38+
"geometry": {
39+
"coordinates": [
40+
[
41+
[
42+
-49.26680302670468,
43+
-2.620018411983054
44+
],
45+
[
46+
-49.26898505564435,
47+
-2.6240857701543234
48+
],
49+
[
50+
-49.26509339578354,
51+
-2.625613834998731
52+
],
53+
[
54+
-49.26291136684472,
55+
-2.6214565953891764
56+
],
57+
[
58+
-49.26680302670468,
59+
-2.620018411983054
60+
]
61+
]
62+
],
63+
"type": "Polygon"
64+
}
65+
}
66+
]
67+
}

examples/plots_analysis.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import datetime
2+
import json
3+
4+
from picterra import PlotsAnalysisPlatformClient
5+
6+
# Replace this with the path to a GeoJSON file containing plot geometries
7+
# as a GeoJSON FeatureCollection of Polygons. In particular, each Feature
8+
# should have a unique "id" property.
9+
plots_feature_collection_filename = "data/plots_analysis/example_plots.geojson"
10+
11+
client = PlotsAnalysisPlatformClient()
12+
13+
# This will run the "EUDR Cocoa" deforestation risk analysis, discarding any
14+
# deforestation alerts happening after 2022-01-01.
15+
print("Starting analysis...")
16+
results = client.batch_analyze_plots(
17+
plots_feature_collection_filename,
18+
"eudr_cocoa",
19+
datetime.datetime.fromisoformat("2022-01-01")
20+
)
21+
22+
# The output of the analysis is a JSON file containing the input plots and their
23+
# associated deforestation risk.
24+
print("Analysis completed:")
25+
print(json.dumps(results, indent=2))

src/picterra/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
from .detector_platform_client import DetectorPlatformClient as APIClient
77
from .detector_platform_client import DetectorPlatformClient
88
from .nongeo import nongeo_result_to_pixel
9+
from .plots_analysis_platform_client import PlotsAnalysisPlatformClient
910

10-
__all__ = ["APIClient", "DetectorPlatformClient", "nongeo_result_to_pixel", "APIError", "ResultsPage"]
11+
__all__ = ["APIClient", "DetectorPlatformClient", "PlotsAnalysisPlatformClient", "nongeo_result_to_pixel", "APIError", "ResultsPage"]
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Handles interfacing with the plots analysis api v1 documented at:
3+
https://app.picterra.ch/public/apidocs/plots_analysis/v1/
4+
5+
Note that that Plots Analysis Platform is a separate product from the Detector platform and so
6+
an API key which is valid for one may encounter permissions issues if used with the other
7+
"""
8+
import datetime
9+
from typing import Literal
10+
11+
import requests
12+
from requests.exceptions import RequestException
13+
14+
from picterra.base_client import APIError, BaseAPIClient
15+
16+
AnalysisMethodology = Literal["eudr_cocoa", "eudr_soy"]
17+
18+
19+
class PlotsAnalysisPlatformClient(BaseAPIClient):
20+
def __init__(self, **kwargs):
21+
super().__init__("public/api/plots_analysis/v1/", **kwargs)
22+
23+
def batch_analyze_plots(self, plots_geometries_filename: str, methodology: AnalysisMethodology, assessment_date: datetime.datetime):
24+
"""
25+
Runs the specified methodology against the plot geometries stored in the provided file and
26+
returns the analysis results.
27+
28+
Args:
29+
- plots_geometries_filename: Path to a file containing the geometries of the plots to run the
30+
analysis against.
31+
- methodology: which analysis to run.
32+
- assessment_date: the point in time at which the analysis should be evaluated.
33+
34+
Returns: the analysis results as a dict.
35+
"""
36+
# Get an upload URL and analysis ID
37+
resp = self.sess.post(self._full_url("batch_analysis/upload/"))
38+
try:
39+
resp.raise_for_status()
40+
except RequestException as err:
41+
raise APIError(
42+
f"Failure obtaining an upload url and plots analysis ID: {err}"
43+
)
44+
45+
analysis_id = resp.json()["analysis_id"]
46+
upload_url = resp.json()["upload_url"]
47+
48+
# Upload the provided file
49+
with open(plots_geometries_filename, "rb") as fh:
50+
resp = requests.put(upload_url, data=fh.read())
51+
try:
52+
resp.raise_for_status()
53+
except RequestException as err:
54+
raise APIError(f"Failure uploading plots file for analysis: {err}")
55+
56+
# Start the analysis
57+
data = {"methodology": methodology, "assessment_date": assessment_date.isoformat()}
58+
resp = self.sess.post(
59+
self._full_url(f"batch_analysis/start/{analysis_id}/"), data=data
60+
)
61+
try:
62+
resp.raise_for_status()
63+
except RequestException as err:
64+
raise APIError(f"Couldn't start analysis for id: {analysis_id}: {err}")
65+
66+
# Wait for the operation to succeed
67+
op_result = self._wait_until_operation_completes(resp.json())
68+
download_url = op_result["results"]["download_url"]
69+
resp = requests.get(download_url)
70+
try:
71+
resp.raise_for_status()
72+
except RequestException as err:
73+
raise APIError(
74+
f"Failure to download results file from operation id {op_result['id']}: {err}"
75+
)
76+
results = resp.json()
77+
78+
return results
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import datetime
2+
import json
3+
import tempfile
4+
5+
import responses
6+
7+
from picterra import PlotsAnalysisPlatformClient
8+
from tests.utils import (
9+
OP_RESP,
10+
OPERATION_ID,
11+
_add_api_response,
12+
_client,
13+
plots_analysis_api_url,
14+
)
15+
16+
17+
def test_plots_analysis_platform_client_base_url(monkeypatch):
18+
"""
19+
Sanity-check that the client defaults to the correct base url
20+
"""
21+
monkeypatch.setenv("PICTERRA_API_KEY", "1234")
22+
client = PlotsAnalysisPlatformClient()
23+
assert client.base_url == "https://app.picterra.ch/public/api/plots_analysis/v1/"
24+
25+
26+
@responses.activate
27+
def test_analyse_plots(monkeypatch):
28+
# Setup the fake api responses
29+
fake_analysis_id = "1234-4321-5678"
30+
fake_analysis_results = { "foo": "bar" }
31+
_add_api_response(
32+
plots_analysis_api_url("batch_analysis/upload/"),
33+
responses.POST,
34+
{
35+
"analysis_id": fake_analysis_id,
36+
"upload_url": "https://example.com/upload/to/blobstore?key=123567",
37+
},
38+
)
39+
40+
responses.put("https://example.com/upload/to/blobstore?key=123567")
41+
42+
_add_api_response(plots_analysis_api_url(f"batch_analysis/start/{fake_analysis_id}/"), responses.POST, OP_RESP)
43+
_add_api_response(plots_analysis_api_url(f"operations/{OPERATION_ID}/"), responses.GET, {
44+
"status": "success",
45+
"results": {
46+
"download_url": "https://example.com/blobstore/results",
47+
"expiration": "2022-12-31",
48+
}
49+
})
50+
responses.get(
51+
"https://example.com/blobstore/results",
52+
json.dumps(fake_analysis_results)
53+
)
54+
55+
client: PlotsAnalysisPlatformClient = _client(monkeypatch, platform="plots_analysis")
56+
with tempfile.NamedTemporaryFile() as tmp:
57+
with open(tmp.name, "w") as f:
58+
json.dump({"foo": "bar"}, f)
59+
results = client.batch_analyze_plots(
60+
tmp.name,
61+
methodology="eudr_cocoa",
62+
assessment_date=datetime.datetime.fromisoformat("2020-01-01"),
63+
)
64+
assert results == fake_analysis_results

tests/utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import responses
44

5-
from picterra.detector_platform_client import DetectorPlatformClient
5+
from picterra import DetectorPlatformClient, PlotsAnalysisPlatformClient
66

77

88
def _add_api_response(
@@ -37,6 +37,8 @@ def _client(monkeypatch, platform="detector", max_retries=0, timeout=1, **kwargs
3737
monkeypatch.setenv("PICTERRA_API_KEY", "1234")
3838
if platform == "detector":
3939
client = DetectorPlatformClient(timeout=timeout, max_retries=max_retries, **kwargs)
40+
elif platform == "plots_analysis":
41+
client = PlotsAnalysisPlatformClient(timeout=timeout, max_retries=max_retries, **kwargs)
4042
else:
4143
raise NotImplementedError(f"Unrecognised API platform {platform}")
4244
return client
@@ -46,6 +48,10 @@ def detector_api_url(path):
4648
return urljoin(TEST_API_URL, urljoin("public/api/v2/", path))
4749

4850

51+
def plots_analysis_api_url(path):
52+
return urljoin(TEST_API_URL, urljoin("public/api/plots_analysis/v1/", path))
53+
54+
4955
TEST_API_URL = "http://example.com/"
5056
TEST_POLL_INTERVAL = 0.1
5157
OPERATION_ID = 21

0 commit comments

Comments
 (0)