Skip to content
This repository was archived by the owner on Apr 2, 2025. It is now read-only.

Commit 15d448b

Browse files
jkeiferphilvarner
andauthored
Split constraints and opportunity properties into two concepts (#113)
* split constraints/opportunity properties * add better documentation * add json schema idea to constraints adr --------- Co-authored-by: Phil Varner <[email protected]>
1 parent 466a582 commit 15d448b

File tree

12 files changed

+203
-38
lines changed

12 files changed

+203
-38
lines changed

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

8+
9+
## [unreleased]
10+
11+
### Added
12+
13+
none
14+
15+
### Changed
16+
17+
- The concepts of Opportunity search Constraint and Opportunity search result Opportunity Properties are now separate,
18+
recognizing that they have related attributes, but not neither the same attributes or the same values for those attributes.
19+
20+
### Deprecated
21+
22+
none
23+
24+
### Removed
25+
26+
none
27+
28+
### Fixed
29+
30+
none
31+
32+
### Security
33+
34+
none
35+
36+
837
## [0.3.0] - 2024-12-6
938

1039
### Added

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
# STAPI FastAPI - Sensor Tasking API with FastAPI
22

3-
WARNING: The whole [STAPI spec] is very much work in progress, so things are
3+
WARNING: The whole [STAPI spec] is very much a work in progress, so things are
44
guaranteed to be not correct.
55

66
## Usage
77

88
STAPI FastAPI provides an `fastapi.APIRouter` which must be included in
99
`fastapi.FastAPI` instance.
1010

11+
12+
## ADRs
13+
14+
ADRs can be found in in the [adrs](./adrs/README.md) directory.
15+
1116
## Development
1217

1318
It's 2024 and we still need to pick our poison for a 2024 dependency management
@@ -36,12 +41,12 @@ command `pytest`.
3641

3742
This project cannot be run on its own because it does not have any backend
3843
implementations. However, a minimal test implementation is provided in
39-
[`./bin/server.py`](./bin/server.py). It can be run with `uvicorn` as a way to
40-
interact with the API and to view the OpenAPI documentation. Run it like so
41-
from the project root:
44+
[`./tests/application.py`](./tests/application.py). It can be run with
45+
`uvicorn` as a way to interact with the API and to view the OpenAPI
46+
documentation. Run it like so from the project root:
4247

4348
```commandline
44-
uvicorn server:app --app-dir ./bin --reload
49+
uvicorn application:app --app-dir ./tests --reload
4550
```
4651

4752
With the `uvicorn` defaults the app should be accessible at

adrs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ADRs
2+
3+
- [Constraints and Opportunity Properties](./constraints.md)

adrs/constraints.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Constraints and Opportunity Properties
2+
3+
Previously, the Constraints and Opportunity Properties were the same concept/representation. However, these represent distinct but related attributes. Constraints represents the terms that can be used in the filter sent to the Opportunities Search and Order Create endpoints. These are frequently the same or related values that will be part of the STAC Items that are used to fulfill an eventual Order. Opportunity Properties represent the expected range of values that these STAC Items are expected to have. An opportunity is a prediction about the future, and as such, the values for the Opportunity are fuzzy. For example, the sun azimuth angle will (likely) be within a predictable range of values, but the exact value will not be known until after the capture occurs. Therefore, it is necessary to describe the Opportunity in a way that describes these ranges.
4+
5+
For example, for the concept of "off_nadir":
6+
7+
The Constraint will be a term "off_nadir" that can be a value 0 to 45.
8+
This is used in a CQL2 filter to the Opportunities Search endpoint to restrict the allowable values from 0 to 15
9+
The Opportunity that is returned from Search has an Opportunity Property "off_nadir" with a description that the value of this field in the resulting STAC Items will be between 4 and 8, which falls within the filter restriction of 0-15.
10+
An Order is created with the original filter and other fields.
11+
The Order is fulfilled with a STAC Item that has an off_nadir value of 4.8.
12+
13+
As of Dec 2024, the STAPI spec says only that the Opportunity Properties must have a datetime interval field `datetime` and a `product_id` field. The remainder of the Opportunity description proprietary is up to the provider to define. The example given this this repo for `off_nadir` is of a custom format with a "minimum" and "maximum" field describing the limits.
14+
15+
## JSON Schema
16+
17+
Another option would be to use either a full JSON Schema definition for an attribute value in the properties (e.g., `schema`) or individual attribute definitions for the properties values. This option should be investigated further in the future.
18+
19+
JSON Schema is a well-defined specification language that can support this type of data description. It is already used as the language for OGC API Queryables to define the constraints on various terms that may be used in CQL2 expressions, and likewise within STAPI for the Constraints that are used in Opportunity Search and the Order Parameters that are set on an order. The use of JSON Schema for Constraints (as with Queryables) is not to specify validation for a JSON document, but rather to well-define a set of typed and otherwise-constrained terms. Similarly, JSON Schema would be used for the Opportunity to define the predicted ranges of properties within the Opportunity that is bound to fulfill an Order.
20+
21+
The geometry is not one of the fields that will be expressed as a schema constraint, since this is part of the Opportunity/Item/Feature top-level. The Opportunity geometry will express both uncertainty about the actual capture area and a “maximum extent” of capture, e.g., a small area within a larger data strip – this is intentionally vague so it can be used to express whatever semantics the provider wants.
22+
23+
The ranges of predicted Opportunity values can be expressed using JSON in the following way:
24+
25+
- numeric value - number with const, enum, or minimum/maximum/exclusiveMinimum/exclusiveMaximum
26+
- string value - string with const or enum
27+
- datetime - type string using format date-time. The limitation wit this is that these values are not treated with JSON Schema as temporal, but rather a string pattern. As such, there is no formal way to define a temporal interval that the instance value must be within. Instead, we will repurpose the description field as a datetime interval in the same format as a search datetime field, e.g., 2024-01-01T00:00:00Z/2024-01-07T00:00:00Z. Optionally, the pattern field can be defined if the valid datetime values also match a regular expression, e.g., 2024-01-0[123456]T.*, which while not as useful semantically as the description interval does provide a formal validation of the resulting object, which waving hand might be useful in some way waving hand .
28+
29+
```json
30+
{
31+
"$schema": "https://json-schema.org/draft/2020-12/schema",
32+
"$id": "schema.json",
33+
"type": "object",
34+
"properties": {
35+
"datetime": {
36+
"title": "Datetime",
37+
"type": "string",
38+
"format": "date-time",
39+
"description": "2024-01-01T00:00:00Z/2024-01-07T00:00:00Z",
40+
"pattern": "2024-01-0[123456]T.*"
41+
},
42+
"sensor_type": {
43+
"title": "Sensor Type",
44+
"type": "string",
45+
"const": "2"
46+
},
47+
"craft_id": {
48+
"title": "Spacecraft ID",
49+
"type": "string",
50+
"enum": [
51+
"7",
52+
"8"
53+
]
54+
},
55+
"view:sun_elevation": {
56+
"title": "View:Sun Elevation",
57+
"type": "number",
58+
"minimum": 30.0,
59+
"maximum": 35.0
60+
},
61+
"view:azimuth": {
62+
"title": "View:Azimuth",
63+
"type": "number",
64+
"exclusiveMinimum": 104.0,
65+
"exclusiveMaximum": 115.0
66+
},
67+
"view:off_nadir": {
68+
"title": "View:Off Nadir",
69+
"type": "number",
70+
"minimum": 0.0,
71+
"maximum": 9.0
72+
},
73+
"eo:cloud_cover": {
74+
"title": "Eo:Cloud Cover",
75+
"type": "number",
76+
"minimum": 5.0,
77+
"maximum": 15.0
78+
}
79+
}
80+
}
81+
```
82+
83+
The Item that fulfills and Order placed on this Opportunity might be like:
84+
85+
86+
```json
87+
{
88+
"type": "Feature",
89+
...
90+
"properties": {
91+
"datetime": "2024-01-01T00:00:00Z",
92+
"sensor_type": "2",
93+
"craft_id": "7",
94+
"view:sun_elevation": 30.0,
95+
"view:azimuth": 105.0,
96+
"view:off_nadir": 9.0,
97+
"eo:cloud_cover": 10.0
98+
}
99+
}
100+
```

src/stapi_fastapi/models/opportunity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11
1313
class OpportunityProperties(BaseModel):
1414
datetime: DatetimeInterval
15+
product_id: str
1516
model_config = ConfigDict(extra="allow")
1617

1718

src/stapi_fastapi/models/product.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from stapi_fastapi.backends.product_backend import ProductBackend
1414

1515

16+
type Constraints = BaseModel
17+
18+
1619
class ProviderRole(StrEnum):
1720
licensor = "licensor"
1821
producer = "producer"
@@ -28,7 +31,7 @@ class Provider(BaseModel):
2831

2932
# redefining init is a hack to get str type to validate for `url`,
3033
# as str is ultimately coerced into an AnyHttpUrl automatically anyway
31-
def __init__(self, url: AnyHttpUrl | str, **kwargs):
34+
def __init__(self, url: AnyHttpUrl | str, **kwargs) -> None:
3235
super().__init__(url=url, **kwargs)
3336

3437

@@ -44,31 +47,38 @@ class Product(BaseModel):
4447
links: list[Link] = Field(default_factory=list)
4548

4649
# we don't want to include these in the model fields
47-
_constraints: type[OpportunityProperties]
50+
_constraints: type[Constraints]
51+
_opportunity_properties: type[OpportunityProperties]
4852
_order_parameters: type[OrderParameters]
4953
_backend: ProductBackend
5054

5155
def __init__(
5256
self,
5357
*args,
5458
backend: ProductBackend,
55-
constraints: type[OpportunityProperties],
59+
constraints: type[Constraints],
60+
opportunity_properties: type[OpportunityProperties],
5661
order_parameters: type[OrderParameters],
5762
**kwargs,
5863
) -> None:
5964
super().__init__(*args, **kwargs)
6065
self._backend = backend
6166
self._constraints = constraints
67+
self._opportunity_properties = opportunity_properties
6268
self._order_parameters = order_parameters
6369

6470
@property
6571
def backend(self: Self) -> ProductBackend:
6672
return self._backend
6773

6874
@property
69-
def constraints(self: Self) -> type[OpportunityProperties]:
75+
def constraints(self: Self) -> type[Constraints]:
7076
return self._constraints
7177

78+
@property
79+
def opportunity_properties(self: Self) -> type[OpportunityProperties]:
80+
return self._opportunity_properties
81+
7282
@property
7383
def order_parameters(self: Self) -> type[OrderParameters]:
7484
return self._order_parameters

src/stapi_fastapi/routers/product_router.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ def __init__(
5151
methods=["POST"],
5252
response_class=GeoJSONResponse,
5353
# unknown why mypy can't see the constraints property on Product, ignoring
54-
response_model=OpportunityCollection[Geometry, self.product.constraints], # type: ignore
54+
response_model=OpportunityCollection[
55+
Geometry,
56+
self.product.opportunity_properties, # type: ignore
57+
],
5558
summary="Search Opportunities for the product",
5659
tags=["Products"],
5760
)

bin/server.py renamed to tests/application.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from datetime import datetime, timezone
2+
from typing import Literal, Self
23
from uuid import uuid4
34

45
from fastapi import FastAPI, Request
6+
from pydantic import BaseModel, Field, model_validator
57
from returns.maybe import Maybe
68
from returns.result import Failure, ResultE, Success
79

@@ -108,10 +110,27 @@ async def create_order(
108110
return Failure(e)
109111

110112

111-
class MyOpportunityProperties(OpportunityProperties):
113+
class MyProductConstraints(BaseModel):
112114
off_nadir: int
113115

114116

117+
class OffNadirRange(BaseModel):
118+
minimum: int = Field(ge=0, le=45)
119+
maximum: int = Field(ge=0, le=45)
120+
121+
@model_validator(mode="after")
122+
def validate_range(self) -> Self:
123+
if self.minimum > self.maximum:
124+
raise ValueError("range minimum cannot be greater than maximum")
125+
return self
126+
127+
128+
class MyOpportunityProperties(OpportunityProperties):
129+
off_nadir: OffNadirRange
130+
vehicle_id: list[Literal[1, 2, 5, 7, 8]]
131+
platform: Literal["platform_id"]
132+
133+
115134
class MyOrderParameters(OrderParameters):
116135
s3_path: str | None = None
117136

@@ -135,7 +154,8 @@ class MyOrderParameters(OrderParameters):
135154
keywords=["test", "satellite"],
136155
providers=[provider],
137156
links=[],
138-
constraints=MyOpportunityProperties,
157+
constraints=MyProductConstraints,
158+
opportunity_properties=MyOpportunityProperties,
139159
order_parameters=MyOrderParameters,
140160
backend=product_backend,
141161
)

tests/conftest.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,21 @@
1414
Opportunity,
1515
)
1616
from stapi_fastapi.models.product import (
17-
OrderParameters,
1817
Product,
1918
Provider,
2019
ProviderRole,
2120
)
2221
from stapi_fastapi.routers.root_router import RootRouter
2322

24-
from .backends import MockOrderDB, MockProductBackend, MockRootBackend
25-
from .shared import SpotlightOpportunityProperties, SpotlightOrderParameters, find_link
26-
27-
28-
class TestSpotlightOrderParameters(OrderParameters):
29-
s3_path: str | None = None
23+
from .application import (
24+
MockOrderDB,
25+
MockProductBackend,
26+
MockRootBackend,
27+
MyOpportunityProperties,
28+
MyOrderParameters,
29+
MyProductConstraints,
30+
)
31+
from .shared import find_link
3032

3133

3234
@pytest.fixture(scope="session")
@@ -62,8 +64,9 @@ def mock_product_test_spotlight(
6264
keywords=["test", "satellite"],
6365
providers=[mock_provider],
6466
links=[],
65-
constraints=SpotlightOpportunityProperties,
66-
order_parameters=SpotlightOrderParameters,
67+
constraints=MyProductConstraints,
68+
opportunity_properties=MyOpportunityProperties,
69+
order_parameters=MyOrderParameters,
6770
backend=product_backend,
6871
)
6972

@@ -140,10 +143,13 @@ def mock_test_spotlight_opportunities() -> list[Opportunity]:
140143
type="Point",
141144
coordinates=Position2D(longitude=0.0, latitude=0.0),
142145
),
143-
properties=SpotlightOpportunityProperties(
146+
properties=MyOpportunityProperties(
144147
product_id="xyz123",
145148
datetime=(start, end),
146-
off_nadir=20,
149+
off_nadir={"minimum": 20, "maximum": 22},
150+
vehicle_id=[1],
151+
platform="platform_id",
152+
other_thing="abcd1234",
147153
),
148154
),
149155
]

tests/shared.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,5 @@
11
from typing import Any
22

3-
from stapi_fastapi.models.opportunity import OpportunityProperties
4-
from stapi_fastapi.models.order import OrderParameters
5-
6-
7-
class SpotlightOpportunityProperties(OpportunityProperties):
8-
off_nadir: int
9-
10-
11-
class SpotlightOrderParameters(OrderParameters):
12-
s3_path: str | None = None
13-
14-
153
type link_dict = dict[str, Any]
164

175

0 commit comments

Comments
 (0)