diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index fb1be91ea..bc914a979 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -135,7 +135,7 @@ jobs:
python -m coverage report
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v3
+ uses: codecov/codecov-action@v4
with:
files: coverage.xml
deploy:
diff --git a/.gitignore b/.gitignore
index 54ea62f14..7fcdd7723 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ polytope_venv_latest
new_updated_numpy_venv
newest-polytope-venv
serializedTree
+new_polytope_venv
diff --git a/docs/Developer_Guide/API.md b/docs/Algorithm/Developer_Guide/API.md
similarity index 100%
rename from docs/Developer_Guide/API.md
rename to docs/Algorithm/Developer_Guide/API.md
diff --git a/docs/Developer_Guide/Axis_types.md b/docs/Algorithm/Developer_Guide/Axis_types.md
similarity index 100%
rename from docs/Developer_Guide/Axis_types.md
rename to docs/Algorithm/Developer_Guide/Axis_types.md
diff --git a/docs/Developer_Guide/Datacube.md b/docs/Algorithm/Developer_Guide/Datacube.md
similarity index 100%
rename from docs/Developer_Guide/Datacube.md
rename to docs/Algorithm/Developer_Guide/Datacube.md
diff --git a/docs/Developer_Guide/Overview.md b/docs/Algorithm/Developer_Guide/Overview.md
similarity index 100%
rename from docs/Developer_Guide/Overview.md
rename to docs/Algorithm/Developer_Guide/Overview.md
diff --git a/docs/Developer_Guide/Slicer.md b/docs/Algorithm/Developer_Guide/Slicer.md
similarity index 100%
rename from docs/Developer_Guide/Slicer.md
rename to docs/Algorithm/Developer_Guide/Slicer.md
diff --git a/docs/Developer_Guide/images/Polytope_APIs_3.png b/docs/Algorithm/Developer_Guide/images/Polytope_APIs_3.png
similarity index 100%
rename from docs/Developer_Guide/images/Polytope_APIs_3.png
rename to docs/Algorithm/Developer_Guide/images/Polytope_APIs_3.png
diff --git a/docs/Developer_Guide/images/polytope_components_5.png b/docs/Algorithm/Developer_Guide/images/polytope_components_5.png
similarity index 100%
rename from docs/Developer_Guide/images/polytope_components_5.png
rename to docs/Algorithm/Developer_Guide/images/polytope_components_5.png
diff --git a/docs/Developer_Guide/images/slicing_process.png b/docs/Algorithm/Developer_Guide/images/slicing_process.png
similarity index 100%
rename from docs/Developer_Guide/images/slicing_process.png
rename to docs/Algorithm/Developer_Guide/images/slicing_process.png
diff --git a/docs/Developer_Guide/shapes.md b/docs/Algorithm/Developer_Guide/shapes.md
similarity index 100%
rename from docs/Developer_Guide/shapes.md
rename to docs/Algorithm/Developer_Guide/shapes.md
diff --git a/docs/Overview/Overview.md b/docs/Algorithm/Overview/Overview.md
similarity index 56%
rename from docs/Overview/Overview.md
rename to docs/Algorithm/Overview/Overview.md
index 8efc1a3d9..e4224f318 100644
--- a/docs/Overview/Overview.md
+++ b/docs/Algorithm/Overview/Overview.md
@@ -10,30 +10,29 @@ Developed by ECMWF - the European Centre for Medium-Range Weather Forecasts - it
### Traditional Extraction Techniques
-Traditional data extraction techniques only allow users to access datacubes "orthogonally" by selecting specific values or ranges along datacube dimensions.
-Such data access mechanisms can be seen as extracting so-called "bounding boxes" of data.
-These mechanisms are quite limited however as many user requests cannot be formulated using bounding boxes.
+Traditional data extraction techniques only allow users to access boxes of data from datacubes.
+These techniques are quite restrictive however as many user requests cannot be formulated using such boxes.
!!!note "Example"
- Imagine for example someone interested in extracting temperature data over the shape of France.
- France is not a box shape over latitude and longitude.
- Using current extraction techniques, this exact request would therefore be impossible and users would instead need to request a bounding box around France.
+ Imagine for example someone interested in extracting wind data over the Mediterranean sea.
+ The Mediterranean is not a box shape over latitude and longitude.
+ Using current extraction techniques, this exact request would therefore be impossible and users would instead need to request a bounding box around the Mediterranean.
The user would thus get back much more data than he truly needs.
In higher dimensions, this becomes an even bigger challenge with only tiny fractions of the extracted data being useful to users.
### Polytope Extraction Technique
-As an alternative, Polytope enables users to access datacubes "non-orthogonally".
-Instead of extracting bounding boxes of data, Polytope has the capability of querying high-dimensional "polytopes" along several axes of a datacube.
-This is much less restrictive than the popular bounding box approach described before.
+Instead, Polytope enables users to access high-dimensional "polytopes" from datacubes, rather than only boxes of data.
+
+
!!!note "Example"
- Using Polytope, extracting the temperature over just the shape of France is now trivially possible by specifying the right polytope.
+ Using Polytope, extracting the temperature over just the shape of the Mediterranean is now trivially possible by specifying the right polytope.
This returns much less data than by using a bounding box approach.
These polytope-based requests do in fact allow Polytope to fulfill its two main aims.
-Indeed, because polytope requests return only the exact data users need, they significantly reduce I/O usage as less data has to be transmitted.
+Indeed, because polytope requests return only the data users need, they significantly reduce I/O usage as less data has to be transmitted.
Moreover, because only the data inside the requested polytope is returned, this method completely removes the challenge of post-processing on the user side, as wanted.
\ No newline at end of file
diff --git a/docs/Overview/Polytope_at_ECMWF.md b/docs/Algorithm/Overview/Polytope_at_ECMWF.md
similarity index 100%
rename from docs/Overview/Polytope_at_ECMWF.md
rename to docs/Algorithm/Overview/Polytope_at_ECMWF.md
diff --git a/docs/Overview/images_overview/ecmwf_datacube.png b/docs/Algorithm/Overview/images_overview/ecmwf_datacube.png
similarity index 100%
rename from docs/Overview/images_overview/ecmwf_datacube.png
rename to docs/Algorithm/Overview/images_overview/ecmwf_datacube.png
diff --git a/docs/Overview/images_overview/ecmwf_polytope.png b/docs/Algorithm/Overview/images_overview/ecmwf_polytope.png
similarity index 100%
rename from docs/Overview/images_overview/ecmwf_polytope.png
rename to docs/Algorithm/Overview/images_overview/ecmwf_polytope.png
diff --git a/docs/Algorithm/User_Guide/Building_Features.md b/docs/Algorithm/User_Guide/Building_Features.md
new file mode 100644
index 000000000..2ed8380fb
--- /dev/null
+++ b/docs/Algorithm/User_Guide/Building_Features.md
@@ -0,0 +1,41 @@
+# Building Features
+
+The Polytope software implements a set of base shapes that might be of interest to users. These are detailed [here](../Developer_Guide/shapes.md).
+
+For many applications however, these shapes are not directly of interest and should rather be used as building blocks for more complex and domain-specific "features", such as timeseries or country areas.
+
+The main requirement when building such features in Polytope is that the feature should be defined on all dimensions of the provided datacube.
+This implies that, when defining lower-dimensional shapes in higher-dimensional datacubes, the remaining axes still need to be specified within the Polytope request (most likely as *Select* shapes).
+
+For example, for a given datacube with dimensions "level", "step", "latitude" and "longitude", we could query the following shapes:
+
+- a timeseries of a point which would be defined as
+
+ Request(
+ Point(["latitude", "longitude"], [[p1_lat, p1_lon]]),
+ Span("step", start_step, end_step),
+ Select("level", [level1])
+ )
+
+
+- a specific country area which would be defined as
+
+ Request(
+ Polygon(["latitude", "longitude"], country_points),
+ Select("step", [step1]),
+ Select("level", [level1])
+ )
+
+- a flight path which would be defined as
+
+ Request(
+ Path(
+ ["latitude", "longitude", "level", "step"],
+ Box(
+ ["latitude", "longitude", "level", "step"],
+ [0, 0, 0, 0],
+ [lat_padding, lon_padding, level_padding, step_padding]
+ ),
+ flight_points
+ )
+ )
diff --git a/docs/User_Guide/Example.md b/docs/Algorithm/User_Guide/Example.md
similarity index 96%
rename from docs/User_Guide/Example.md
rename to docs/Algorithm/User_Guide/Example.md
index 10ea2a510..8e5b903f3 100644
--- a/docs/User_Guide/Example.md
+++ b/docs/Algorithm/User_Guide/Example.md
@@ -1,7 +1,7 @@
# Example
Here is a step-by-step example of how to use the Polytope software.
-1. In this example, we first specify the data which will be in our Xarray datacube. Note that the data here comes from the GRIB file called "winds.grib", which is 3-dimensional with dimensions: step, latitude and longitude.
+1. In this example, we first specify the data which will be in our XArray datacube. Note that the data here comes from the GRIB file called "winds.grib", which is 3-dimensional with dimensions: step, latitude and longitude.
import xarray as xr
diff --git a/docs/User_Guide/Getting_started.md b/docs/Algorithm/User_Guide/Getting_started.md
similarity index 95%
rename from docs/User_Guide/Getting_started.md
rename to docs/Algorithm/User_Guide/Getting_started.md
index fcaf042b6..57f76b248 100644
--- a/docs/User_Guide/Getting_started.md
+++ b/docs/Algorithm/User_Guide/Getting_started.md
@@ -26,13 +26,13 @@ or from PyPI with the command
Polytope's tests and examples require some additional dependencies compared to the main Polytope software.
-- **Git Large File Storage**
+
- **Additional Dependencies**
diff --git a/docs/User_Guide/Overview.md b/docs/Algorithm/User_Guide/Overview.md
similarity index 95%
rename from docs/User_Guide/Overview.md
rename to docs/Algorithm/User_Guide/Overview.md
index 0e9b36d5a..4da049e19 100644
--- a/docs/User_Guide/Overview.md
+++ b/docs/Algorithm/User_Guide/Overview.md
@@ -15,6 +15,8 @@ For a quick guide of how to install and use Polytope, refer to the links below:
- Example
+- Building Features
+
!!!note
An exhaustive list of all shapes that can currently be requested using Polytope can be found [here](../Developer_Guide/shapes.md).
diff --git a/docs/User_Guide/images_users/shipping_route.png b/docs/Algorithm/User_Guide/images_users/shipping_route.png
similarity index 100%
rename from docs/User_Guide/images_users/shipping_route.png
rename to docs/Algorithm/User_Guide/images_users/shipping_route.png
diff --git a/docs/Service/Data_Portfolio.md b/docs/Service/Data_Portfolio.md
new file mode 100644
index 000000000..52fd1d9d9
--- /dev/null
+++ b/docs/Service/Data_Portfolio.md
@@ -0,0 +1,51 @@
+# Data Portfolio
+
+Polytope feature extraction only has access to data that is stored on an FDB. The dataset currently available via Polyope feature extraction is the operational forecast. We plan to add Destination Earth Digital Twin data in the future.
+
+## Operational Forecast Data
+
+The following values available for each field specified are:
+
+* `class` : `od`
+* `stream` : `enfo` `oper`
+* `type` : `fc` `pf` `cf`
+* `levtype` : `sfc` `pl` `ml`
+* `expver` : `0001`
+* `domain` : `g`
+* `step` : `0/to/360` (All steps may not be available between `0` and `360`)
+
+If `type` is `enfo`:
+
+* `number` : `0/to/50`
+
+If `levtype` is `pl` or `ml` a `levelist` must be provided:
+
+* `levelist` : `1/to/1000`
+
+`pl` and `ml` also only contain a subset of parameters that are available in grid point. These are:
+
+* `pl`
+ * `o3`
+ * `clwc`
+ * `q`
+ * `pv`
+ * `ciwc`
+ * `cc`
+* `ml`
+ * `q`
+ * `cat`
+ * `o3`
+ * `clwc`
+ * `ciwc`
+ * `cc`
+ * `cswc`
+ * `crwe`
+ * `ttpha`
+
+For `sfc` most `params` will be available but not all.
+
+Only data that is contained in the operational FDB can be requested via Polytope feature extraction, the FDB usually only contains the last two days of forecasts.
+
+We sometimes limit the size of requests for area features such as bounding box and polygon to maintain quality of service.
+
+Access to operational data is limited by our release schedule.
diff --git a/docs/Service/Design_doc.md b/docs/Service/Design_doc.md
new file mode 100644
index 000000000..545f4aa73
--- /dev/null
+++ b/docs/Service/Design_doc.md
@@ -0,0 +1,484 @@
+# Polytope-mars
+
+## Feature Documentation
+
+### Feature Keyword
+
+Feature extraction expands existing mars requests to include a `feature` keyword that includes a json dictionary that describes the given feature. This feature is then extracted using the Polytope feature extraction algorithm and only points within the given feature are returned.
+
+```python
+"feature" : {
+ "type" : "timeseries",
+ "points" : [[-9.109280931080349, 38.78655345978706]],
+}
+```
+
+#### Type
+
+An example of a minimal feature of `type` : `timeseries` can be seen above. A feature dictionary must always contain a `type`. The `type` in this case refers to what feature is being requested, the `type` of feature requested will then determine the format of the output returned, what other keys can go in the feature and suitable defaults if they are not available. In some cases it may also affect keys outside of the feature dictionary that come from the traditional mars request. For example if `type` : `verticalprofile` and `levtype` : `sfc`, this request will not be sent as a vertical profile expects either `levtype` : `pl/ml`. Other exceptions will be given for each separate feature `type`.
+
+The value available for `type` currently are as follows:
+
+* `timeseries`
+* `verticalprofile`
+* `polygon`
+* `trajectory`
+* `frame`
+* `boundingbox`
+
+More feature types will be added in the future.
+
+#### Geometry
+
+A feature dictionary must also contain the requested geometry in some form. For a `timeseries` as seen above this comes in the form `points` which requests a timeseries at a given point, however this geometry is not always a point and depends upon `type`. The geometry is a mandatory field for all features.
+
+#### Axis
+
+A non-mandatory field that is available for each feature that is not present in the above example is `axis`. `axis` determines what field that the data should be enumerated along. In the case of a `timeseries` this will default to `step` meaning that the timeseries will be along the `step` axis, however there are other available `axes` such as `datetime`, this would be for climate data which contains no `step` `axis`.
+
+#### Range
+
+`range` is a json dictionary that is available for some features, it contains the extents of a given a `axes`. For example:
+
+```python
+"range" : {
+ "start" : 0,
+ "end" : 10,
+ "interval" : 2,
+}
+```
+
+If this range was included in the above feature dictionary for a `timeseries` it would ask for `step` (due to it being the default axis for timeseries) starting at `0` and ending at `10` with an interval of `2`, the returned steps would be `0,2,4,6,8,10`. This is equivalent to asking for the following in a mars request:
+
+```python
+"step" : "0/to/10/by/2"
+```
+
+The above can also be put in the body of the request. However it must then be mutually exclusive with `range`. If both or neither are in the request an error is thrown.
+
+`range` can also appear in the following form:
+
+```python
+"range" : [0,1,4,7,10]
+```
+
+This will only return the asked steps similar to in a MARS request where a user asks for the following:
+
+```python
+"step" : "0/1/4/7/10"
+```
+
+Again either a `range` within the feature or an explicit `step` within the main body of the request can be used but not both or neither as there is no suitable default value unlike MARS.
+
+
+### MARS Fields
+
+The non `feature` elements of the polytope-mars request act similar to the way one would expect when creating a MARS request with a few differences.
+
+* Most fields do not have a default value that will be tried if the field is not in the request.
+* If a user makes a request and data is only available for some of the fields requested an error will be returned. Users will either receive all the data they requested or none.
+* All key/value pairs must be in the form of a string n the main body of the request. Only values in the `feature` can be non-string types.
+* Fields that can also be in the `feature` dictionary can either be provided in the main body of the request or in the `feature` but not both otherwise an error will be thrown. Both of the following examples are valid.
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20241006",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "step": "0/to/360",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axis": "step",
+ },
+}
+```
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20241006",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axis": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ }
+ },
+}
+```
+
+However the following is not:
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20241006",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "step" : "0/to/360",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axis": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ }
+ },
+}
+```
+
+The above would throw an error that `step` has been over-subscribed.
+
+Ideally an valid mars request should be able to accept a valid `feature` and the polytope-mars request be valid but this may not always be true.
+
+Users can include the `format` key. However, initially the only value available will be `covjson` or `application/json+covjson`, these will be the default values if `format` is not included. Further formats may be added in the future.
+
+### Features
+
+The following features will be available for use in polytope-mars.
+
+#### Timeseries
+
+A timeseries request has a `feature` with `type` : `timeseries` and a geometry in the form of `points` containing a single point with latitude and longitude values. It also requires at least one time dimension with the default being `step`, although `datetime` is also accepted. The following is an example of a timeseries request:
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20241006",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axis": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ }
+ },
+}
+```
+
+This is equivilent to:
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20241006",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "step" : "0/to/360",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axis": "step",
+ },
+}
+```
+
+In this case the user is requesting `step` `0-360` on `20241006` for the point `[-9.10, 38.78]`. As the user does not specify `interval` all steps between `0-360` that are available. If the datacube is a climate dataset that does not contain step, an error would be thrown as `step` is not in the datacube. In this case the user would have to provide a request like the following:
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "step" : "0/to/360",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axis": "datetime",
+ "range": {
+ "start" : "20241006T000000",
+ "end" : "20241009T000000",
+ }
+ },
+}
+```
+
+Here a user will receive a `timeseries` for the dates `20241006T000000` to `20241009T000000` but not including the final date. The user can also provide an `interval` like `1d` meaning only intervals of 1 day are provided so in this case the following datetimes are returned.
+
+* `20241006T000000`
+* `20241007T000000`
+* `20241008T000000`
+
+If a dataset contains both a `step` and `datetime` the user can still request `axis` : `datetime`, and this request will return a timeseries across `datetime` rather than `step`.
+
+In the above case if a range is provided for a field such as `number` a time series as described above will be provided per `number` or any other range field.
+
+CoverageJSON output type: PointSeries
+
+#### Vertical Profile
+
+A vertical profile request has a `feature` with `type` : `verticalprofile` and a geometry in the form of `points` containing a single point with latitude and longitude values. It also requires a `levtype` that is not `sfc` and a `levelist` in the request or as part of the `feature`. The following is an example of a vertical profile request:
+
+```python
+request = {
+ "class": "od",
+ "stream": "enfo",
+ "type": "pf",
+ "date": "20240925",
+ "time": "0000",
+ "levtype": "pl",
+ "expver": "0079",
+ "domain": "g",
+ "param": "203/133",
+ "number": "1",
+ "step": "0",
+ "levelist": "1/to/1000",
+ "feature": {
+ "type": "verticalprofile",
+ "points": [[38.9, -9.1]],
+ },
+}
+```
+
+The following is equivilent to the above:
+
+```python
+request = {
+ "class": "od",
+ "stream": "enfo",
+ "type": "pf",
+ "date": "20240925",
+ "time": "0000",
+ "levtype": "pl",
+ "expver": "0079",
+ "domain": "g",
+ "param": "203/133",
+ "number": "1",
+ "step": "0",
+ "levelist": "1/to/1000",
+ "feature": {
+ "type": "verticalprofile",
+ "points": [[38.9, -9.1]],
+ "range" : {
+ "start" : 1,
+ "end" : 1000
+ }
+ },
+}
+```
+
+`levtype` can either be `ml` or `pl` but at least one must be present.
+
+`levelist` can either be in the main body of the request or in `range` as described in the `range` section. If no `interval` is provided all values in from `start` to `end` will be requested.
+
+Currently the default for `axes` is `levelist` and is the only valid value for this key. This may change in the future. Users can include this in the request but it is not necessary.
+
+In the above case if a range is provided for a field such as `number`, a vertical profile as described above will be provided per `number` or any other range field.
+
+CoverageJSON output type: VerticalProfile
+
+#### Trajectory
+
+A trajectory request has a `feature` with `type` : `trajectory` and a geometry in the form of `points` containing at least two points with latitude and longitude, a level value, and a time value if no `axes` is provided. This is because the default `axes` are as follows:
+
+```python
+"axes" : ["lat", "long", "level", "step"]
+```
+
+An example using default `axes` if found below.
+
+```python
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20240930",
+ "time" : "0000",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "levtype" : "pl",
+ "number" : "1",
+ "feature" : {
+ "type" : "trajectory",
+ "points" : [[-1, -1, 1000, 0], [0, 0, 1000, 12], [1, 1, 250, 24]],
+ },
+}
+```
+
+This request will return a trajectory with forecast date of `20240930T000000` for the three requested parameters for the points:
+
+* `lat: -1, long: -1, pressure level: 1000, step: 0`
+* `lat: 0, long: 0, pressure level: 1000, step: 12`
+* `lat: 1, long: 1, pressure level: 250, step: 24`
+
+The `trajectory` `feature` also contains another field called `padding` with a default of 1. This is the radius of the circle swept around the trajectory where points within this radius are returned to the user.
+
+`axes` must contain at minimum `lat` and `long` however a time and level axes are optional if provided in the main body of the request. The level and time axes can also take different values such as `step` or `datetime` for the time axes.
+
+The following is an example of a combination of `axes` that will cause an error:
+
+```python
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20240930",
+ "time" : "0000",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "levtype" : "pl",
+ "number" : "1",
+ "feature" : {
+ "type" : "trajectory",
+ "points" : [[-1, 0], [0, 12], [1, 24]],
+ "axis" : ['lat', 'step']
+ },
+}
+```
+This is due to the fact that `long` is required to be in `axis`.
+
+A valid example of leaving out a time `axis` is as follows:
+
+```python
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20240930",
+ "time" : "0000",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "levtype" : "pl",
+ "number" : "1",
+ "step" : "0/1"
+ "feature" : {
+ "type" : "trajectory",
+ "points" : [[-1, -1, 1000], [0, 0, 1000], [-1, -1, 250]],
+ "axis" : ['lat', 'long', 'level']
+ },
+}
+```
+
+In this case the following data will be returned.
+
+* `lat: -1, long: -1, pressure level: 1000, step: 0`
+* `lat: 0, long: 0, pressure level: 1000, step: 0`
+* `lat: 1, long: 1, pressure level: 250, step: 0`
+
+* `lat: -1, long: -1, pressure level: 1000, step: 1`
+* `lat: 0, long: 0, pressure level: 1000, step: 1`
+* `lat: 1, long: 1, pressure level: 250, step: 1`
+
+These will be two of the same trajectories but on different steps.
+
+If `step` however was not specified outside of the `feature` the above would give an error that the time `axis` is underspecified.
+
+For any other ranged fields provided in the main request this will replicate the above returned data but per value. For example if ensemble `number` : `1/2` the same data as above would be provided for each `number`.
+
+CoverageJSON output type: Trajectory
+
+#### Polygon
+
+A polygon request has a `feature` with `type` : `poylgon` and a geometry in the form of `shape` containing at least one list containing three points with latitude and longitude with the first and final point being the same to complete the polygon. The user can provide multiple lists of points forming polygons in the same request. An example of the `polygon` feature is seen below:
+
+```python
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20240930",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1",
+ "step": "0",
+ "feature" : {
+ "type" : "polygon",
+ "shape" : [[-1, 1], [-1, 0], [0, 1], [-1, 1]],
+ },
+}
+```
+
+If a user requests a a range for any of `step`, `number`, or `date` a polygon cutout for each field will be returned with a cutout of the data within the polygon. For example:
+
+```python
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : "20240930",
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0079",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/2",
+ "step": "0/1",
+ "feature" : {
+ "type" : "polygon",
+ "shape" : [[-1, 1], [-1, 0], [0, 1], [-1, 1]],
+ },
+}
+```
+
+This request will return a polygon for each number and each step within that given number.
+Returned coverages as polygons:
+
+* `number: 1, step: 0, Points within shape`
+* `number: 1, step: 1, Points within shape`
+* `number: 2, step: 0, Points within shape`
+* `number: 2, step: 1, Points within shape`
+
+Each of these will be an individual coverage with the 3 requested parameters.
+
+The `polygon` feature currently has limits on the size of a returned polygon and the maximum number of points allowed for a requested polygon.
+
+CoverageJSON output type: MultiPoint
+
+### Covjson
+
+
+CoverageJSON has a number of different output features. Depending on the feature selected the output type will vary.
+
+A coverageCollection is always returned even if there is only a single coverage.
+A new coverage is created for each ensemble number and depending on the feature type each new date (except in timeseries). The only grouped field is `param` which will be in the same coverage.
\ No newline at end of file
diff --git a/docs/Service/Examples/boundingbox_example.ipynb b/docs/Service/Examples/boundingbox_example.ipynb
new file mode 100644
index 000000000..14a33a5b6
--- /dev/null
+++ b/docs/Service/Examples/boundingbox_example.ipynb
@@ -0,0 +1,560 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Bounding Box Example"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import earthkit.data\n",
+ "\n",
+ "request = {\n",
+ " \"class\": \"od\",\n",
+ " \"stream\" : \"enfo\",\n",
+ " \"type\" : \"pf\",\n",
+ " \"date\" : -1, # Note: date must be within the last two days\n",
+ " \"time\" : \"0000\",\n",
+ " \"levtype\" : \"sfc\",\n",
+ " \"expver\" : \"0001\", \n",
+ " \"domain\" : \"g\",\n",
+ " \"param\" : \"164/166/167/169\",\n",
+ " \"number\" : \"1\",\n",
+ " \"step\": \"0\",\n",
+ " \"feature\" : {\n",
+ " \"type\" : \"boundingbox\",\n",
+ " \"points\" : [[53.55, 2.76], [50.66, 7.86]],\n",
+ "\t},\n",
+ "}\n",
+ "\n",
+ "\n",
+ "ds = earthkit.data.from_source(\"polytope\", \"ecmwf-mars\", request, stream=False, address='polytope.ecmwf.int')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Convert to xarray"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ " Note: This notebook is rendered in many different ways depending on where you are viewing it (e.g. GitHub, Jupyter, readthedocs etc.). To maximise compatibility with many possible rendering methods, all interactive plots are rendered with chart.show(renderer=\"png\"), which removes all interactivity and only shows a PNG image render.
\n",
+ " If you are running this notebook in an interactive session yourself and would like to interact with the plots, remove the renderer=\"png\" argument from each call to chart.show().\n",
+ "
\n",
+ " Note: This notebook is rendered in many different ways depending on where you are viewing it (e.g. GitHub, Jupyter, readthedocs etc.). To maximise compatibility with many possible rendering methods, all interactive plots are rendered with chart.show(renderer=\"png\"), which removes all interactivity and only shows a PNG image render.
\n",
+ " If you are running this notebook in an interactive session yourself and would like to interact with the plots, remove the renderer=\"png\" argument from each call to chart.show().\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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31iXvrZBbf325xZ5PGmaNrbfSW8nUPXmZ/vNbHaLTPvw+uru3+Nu62UWHAgggIDTBQi8Tu9h2ocAAqclkJqaphadntB1DepocO8Op1VHToUCVW++3SAVIYAAAg4UIPA6sFNpEgIInLrADz//oVdmLVCdi89TVIniWvn1Ousht/mvx+q86medeoXHSwSq3tO+IQoigAACLhQg8Lqw02kyAghkFzCv33126jvavHWHtbPBedXPVpe7m+rSi2rY4gpUvbZuisIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAggggAACCLhNgMDrth6nvQgggAACCCCAgMsECLwu63CaiwACCCCAAAIIuE2AwOu2Hqe9CCCAAAIIIICAywQIvC7rcJqLAAIIIIAAAgi4TYDA67Yep70IIIAAAggggIDLBAi8LutwmosAAoVP4PCRowoPD1N4WFjhuznuCAEEEHCAAIHXAZ1IExBAoOgJxK3+Ue/M/1zf//Snkg8dthpQt/b56tWlla6+4uKi1yDuGAEEECjEAgTeQtw53BoCCDhXoO+IF7U47ls1u/kq1ah2pvYkJOmd+cusBr/36lO68Lyqzm08LUMAAQSCLEDgDTI4l0MAAQSMwJIV3+nyS85TpQpl0kHe+3ilho37r/r3uFtd7mkGFAIIIIBAPgkQePMJkmoQQAAB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unat6tbsbaUK3ofUNm7ZboXgrdt3WV/f2fQ6K1D7ZovN98y2ZAReF/1toakIIIAAAgggUCQFinTg9UfczAKbh9AqlS+TJaz6yibtP6iUlGPpa3VPrHPnrkRrP97oqBLZLpceeGctk4plfxObP/fHOQgggAACCCCAAAKBFXB84A0kX3rgnblUKp49EAfy2tSNAAIIIIAAAggg4J8Agdc/pxzPSg+8M5ZIJdguxAYlRRFAAAEEEEAAgYAJEHht0PoCb8wbnyokqqSNmiiKAAIIIIAAAgggECgBAq8N2fTA+99FCilZykZNFEUAAQQQQAABBBAIlACB14ZseuB97WOFlIqxURNFEUAAAQQQQAABBAIlQOC1IZseeF9dqJDS3u3MOBBAAAEEEEAAAQQKlwCB10Z/pAfeV+YrpEx5GzVRFAEEEEAAAQQQQCBQAgReG7LpgXfahwopV8FGTRRFAAEEEEAAAQQQCJQAgdeGbHrgfel9hZSvZKMmiiKAAAIIIIAAAggESoDAa0PWF3hLvzhPoRXPsFETRRFAAAEEEEAAAQQCJUDgtSGbHnhfeFehlc60URNFEUAAAQQQQAABBAIlQOC1IZseeJ+fo9AzzrJRE0URQAABBBBAAAEEAiVA4LUhmx54J7+t0Crn2KiJoggggAACCCCAAAKBEiDw2pBND7wTZyv0rGo2aqIoAggggAACCCCAQKAECLw2ZNMD74SZCj27uo2aKIoAAggggAACCCAQKAECrw3Z9MA7foZCq9awURNFEUAAAQQQQAABBAIlQOC1IesLvKWefUNh555noyaKIoAAAggggAACCARKgMBrQzY98I79r8KqX2CjJooigAACCCCAAAIIBEqAwGtDNj3wPvOqwmpcaKMmiiKAAAIIIIAAAggESoDAa0M2PfCOma6w8y6yURNFEUAAAQQQQAABBAIlQOC1IZseeEe/rLALLrFRE0URQAABBBBAAAEEAiVA4LUhmx54R72ksFqX2qiJoggggAACCCCAAAKBEiDw2pD1Bd6ST01V+IV1bNREUQQQQAABBBBAAIFACRB4bcimB94RUxR+cV0bNVEUAQQQQAABBBBAIFACBF4bsumBd9jzCq9dz0ZNFEUAAQQQQAABBBAIlACB14ZseuAdOknhl15poyaKIoAAAggggAACCARKgMBrQzY98D45UeF16tuoiaII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"
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from earthkit.plots.interactive import Chart\n",
+ "\n",
+ "chart = Chart()\n",
+ "chart.line(ds, y=\"z\")\n",
+ "chart.fig.update_layout(yaxis1={\"title\": \"hPa\"})\n",
+ "chart.fig.update_layout(yaxis2={\"title\": \"hPa\"})\n",
+ "chart.show(renderer=\"png\") # Replace with chart.show() in an interactive session!"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.6"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/Service/Features/boundingbox.md b/docs/Service/Features/boundingbox.md
new file mode 100644
index 000000000..dfb70a464
--- /dev/null
+++ b/docs/Service/Features/boundingbox.md
@@ -0,0 +1,86 @@
+# Bounding Box
+
+## Basic Example
+
+An example bounding box requested via earthkit-data:
+
+```python
+import earthkit.data
+
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1, # Note: date must be within the last two days
+ "time" : "0000",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "levtype" : "sfc",
+ "number" : "1",
+ "step" : "0",
+ "feature" : {
+ "type" : "boundingbox",
+ "points" : [[-1, -1], [1, 1]],
+ },
+ "format" : "covjson",
+}
+
+ds = earthkit.data.from_source("polytope", "ecmwf-mars", request, stream=False, address='polytope.ecmwf.int')
+```
+
+This request will return a bounding box from yesterday's 00Z forecast for the three requested parameters for the points within a bounding box with top left coordinate at latitude -1 and longitude -1, and bottom right point at latitude 1 and longitude 1.
+
+`"polytope"` refers to the underlying service being used to return the data. `"ecmwf-mars"` is the dataset we are looking to retrieve from. Setting `stream=False` returns all the requested data to us once it is available. `address` points to the endpoint for the polytope server.
+
+
+## Required Fields
+
+For a bounding box, two fields are required within the `feature` dictionary
+
+* `type`
+* `points`
+
+For a bounding box, `type` must be `boundingbox`.
+
+`points` must contain two points, the first corresponding to the top left of the requested box, and the second corresponding to the bottom right coordinate. By default they should only contain a latitude and longitude. However as seen below this can be changed with the `axes` key.
+
+
+## Optional Fields
+
+`axes` refers to the axes on which to generate the bounding box. As stated above the minimum default `axes` contains `latitude` and `longitude` meaning if `axes` is not included these values must be provided per point. By default the level is taken from the main body of the request.
+
+However `axes` can also be provided by the user and with a value for level. Such as here:
+
+```python
+"axes" : ["latitude", "longitude", "levelist"]
+```
+
+In this case the user must provide a `latitude`, `longitude` and `levelist`. `levelist` should not be included in the main body of the request in this case. An example can be seen here:
+
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1,
+ "time" : "0000",
+ "levtype" : "pl",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "203/133",
+ "number" : "1",
+ "step" : "0",
+ "feature" : {
+ "type" : "boundingbox",
+ "points" : [[-0.1, -0.1, 500], [0.1, 0.1, 1000]],
+ "axes" : ["latitude", "longitude", "levelist"]
+ },
+ "format" : "covjson"
+}
+```
+
+For this request, a bounding box with top left corner at lat -1, lon -1 and pressure level 1000, and bottom right corner at lat 1, lon 1, and pressure level 500.
+
+Without level in the `axes` this will be taken from the main body of the request. In the case of `levtype` = `sfc`, no levelist is required.
\ No newline at end of file
diff --git a/docs/Service/Features/feature.md b/docs/Service/Features/feature.md
new file mode 100644
index 000000000..bc866f540
--- /dev/null
+++ b/docs/Service/Features/feature.md
@@ -0,0 +1,22 @@
+# User Guide for Polytope Feature Extraction on ECMWF Data
+
+## Introduction
+
+Polytope is a service which enables users to download "features" from entire datacubes of earth system data. The best way to access this data is via [earthkit-data](https://earthkit-data.readthedocs.io/en/latest/guide/sources.html#polytope).
+
+Follow the links below to see how to request different types of features.
+
+## Feature Documentation
+
+- [Time Series](timeseries.md)
+- [Vertical Profile](vertical_profile.md)
+- [Polygon](polygon.md)
+- [Bounding Box](boundingbox.md)
+- [Trajectory](trajectory.md)
+
+## Notes
+
+Some important notes that hold for all features are that:
+
+* The data has to exist in the fdb on the polytope server.
+* Further details on the `from_source` method can be found here: [https://earthkit-data.readthedocs.io/en/latest/guide/sources.html](https://earthkit-data.readthedocs.io/en/latest/guide/sources.html)
\ No newline at end of file
diff --git a/docs/Service/Features/polygon.md b/docs/Service/Features/polygon.md
new file mode 100644
index 000000000..96423e0bc
--- /dev/null
+++ b/docs/Service/Features/polygon.md
@@ -0,0 +1,85 @@
+# Polygon
+
+## Basic Example
+
+An example polygon requested via earthkit-data:
+
+```python
+import earthkit.data
+
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1, # Note: date must be within the last two days
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1",
+ "step": "0",
+ "feature" : {
+ "type" : "polygon",
+ "shape" : [[-1, 1], [-1, 0], [0, 1]],
+ },
+ "format" : "covjson",
+}
+
+ds = earthkit.data.from_source("polytope", "ecmwf-mars", request, stream=False, address='polytope.ecmwf.int')
+```
+
+This request will return all points contained in `shape` from yesterday's 00Z forecast for `step` `0`, ensemble `number` `1` and the three provided parameters.
+
+`"polytope"` refers to the underlying service being used to return the data. `"ecmwf-mars"` is the dataset we are looking to retrieve from. Setting `stream=False` returns all the requested data to us once it is available. `address` points to the endpoint for the polytope server.
+
+
+## Required Fields
+
+For a polygon two fields are required within the `feature` dictionary
+
+* `type`
+* `shape`
+
+For a polygon `type` must be `polygon`.
+
+The values in `points` must correspond to a latitude and a longitude. The first point is assumed to be the last point, however users can also provide the last point in the request to complete the polygon.
+
+The polygon feature also has a max number of points that can be requested in the perimeter of the polygon, and the max area of the polygon is also constrained based on the config provided.
+
+`shape` can also take multiple polygons in a single request in the following form:
+
+```python
+"shape" : [[[-1, 1], [-1, 0], [0, 1], [-1, 1]], [[-2, 2], [-2, 1], [1, 2], [-2, 2]]],
+```
+
+User can also request ranges for other keys such as `number`. In this case the polygon cutout will be returned for each of the values requested.
+
+```python
+request = {
+ "class" : "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1,
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/2",
+ "step": "0/1",
+ "feature" : {
+ "type" : "polygon",
+ "shape" : [[-1, 1], [-1, 0], [0, 1], [-1, 1]],
+ },
+ "format" : "covjson",
+}
+```
+
+The returned values will be:
+
+* `number: 1, step: 0, Points within shape`
+* `number: 1, step: 1, Points within shape`
+* `number: 2, step: 0, Points within shape`
+* `number: 2, step: 1, Points within shape`
+
diff --git a/docs/Service/Features/timeseries.md b/docs/Service/Features/timeseries.md
new file mode 100644
index 000000000..609242b89
--- /dev/null
+++ b/docs/Service/Features/timeseries.md
@@ -0,0 +1,106 @@
+# TimeSeries
+
+## Basic Example
+
+An example of a time-series requested via earthkit-data:
+
+```python
+import earthkit.data
+
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1, # Note: date must be within the last two days
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axes": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ }
+ },
+ "format": "covjson",
+}
+
+ds = earthkit.data.from_source("polytope", "ecmwf-mars", request, stream=False, address='polytope.ecmwf.int')
+```
+The following will return a timeseries starting yesterday at 00Z with steps from `0` to `360` including all steps available in between, for the parameters `164/167/169` at the point given. This data will be returned for each ensemble number requested.
+
+`"polytope"` refers to the underlying service being used to return the data. `"ecmwf-mars"` is the dataset we are looking to retrieve from. Setting `stream=False` returns all the requested data to us once it is available. `address` points to the endpoint for the polytope server.
+
+## Required Fields
+
+For a timeseries within the `feature` dictionary three fields are required
+
+* `type`
+* `points`
+* `axes`
+
+For a timeseries `type` must be `timeseries`.
+
+`points` must be a nested list with a points containing a latitude and a longitude.
+
+`axes` refers to the axis on which to generate the timeseries. In this case the timeseries is generated across `step` based on the inputted `range`. However if the data requested was a climate dataset the `axess` may be `datetime` denoting that the timeseries is generated across that axis.
+
+
+## Optional Fields
+
+`range` is an optional field within `feature`. It refers to the extent of the `axes` on which the timeseries will be generated. In the above case where:
+
+```python
+ "axes": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ }
+```
+
+A timeseries across `step` will start at step `0` and end at step `360` with all steps found in between being included. `range` can also contain `interval`.
+
+```python
+ "axes": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ "interval" : 2,
+ }
+```
+In this case every second step will be returned if it exists.
+
+As `range` is an optional field it can be left out, however there is not a default value. Instead the user has to include the timeseries `axes` in the main body of the request like below:
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1,
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "step" : "0/to/360",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axes": "step",
+ },
+ "format": "covjson",
+}
+```
+
+This is equivalent to the first request presented.
+
+At least one of `range` or `step` must be included in the request, but not both. In this case an error will be provided telling the user that `step` is overspecified.
+
+Conversely at least one of `range` or `step` must be included.
diff --git a/docs/Service/Features/trajectory.md b/docs/Service/Features/trajectory.md
new file mode 100644
index 000000000..7662f4192
--- /dev/null
+++ b/docs/Service/Features/trajectory.md
@@ -0,0 +1,139 @@
+# Trajectory
+
+## Basic Example
+
+An example trajectory requested via earthkit-data:
+
+```python
+import earthkit.data
+
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1,
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/166/167",
+ "number" : "1",
+ "step": "0",
+ "feature" : {
+ "type" : "trajectory",
+ "points" : [[-0.1, -0.1], [0, 0], [0.1, 0.1]],
+ "inflation" : 0.1,
+ "axes" :["latitude", "longitude"],
+ },
+}
+
+ds = earthkit.data.from_source("polytope", "ecmwf-mars", request, stream=False, address='polytope.ecmwf.int')
+```
+
+
+The `trajectory` `feature` contains another field called `inflation`. This is the inflation of the shape swept around the trajectory where points within this inflation are returned to the user. In this example above, as an `inflate` value is not given, the default is `round`, meaning that the `inflation` acts as a radius around a circle. This request will thus return a trajectory from yesterday's 00Z forecast for the three requested parameters for the points contained in a circle of radius `0.1` along the given path.
+
+`"polytope"` refers to the underlying service being used to return the data. `"ecmwf-mars"` is the dataset we are looking to retrieve from. Setting `stream=False` returns all the requested data to us once it is available. `address` points to the endpoint for the polytope server.
+
+## Required Fields
+
+For a trajectory three fields are required within the `feature` dictionary
+
+* `type`
+* `points`
+* `inflation`
+
+For a trajectory, `type` must be `trajectory`.
+
+The values in `points` can change depending on the `axes`. `axes` can contain the following values:
+
+```python
+"axes" : ["latitude", "longitude", "levelist", "step"]
+```
+
+In this default case, a nested list of at least two points with values for `latitude` and `longitude` must be provided.
+
+Another required field that is within the `feature` dictionary is `inflation`. This refers to the inflation of the shape swept around the trajectory along which points will be included.
+
+`inflation` can be either a single value or a list of values. If it is a single value, this will be the inflation along each of the `axes`. If it is a list of a values, each value will correspond to the inflation of the corresponding `axes` axis.
+
+By default the shape swept around the trajectory is `round` but this can be overridden using the `inflate` keyword below.
+
+
+## Optional Fields
+
+`axes` refers to the axes on which to generate the trajectory. As stated above, the minimum default `axes` contains `latitude`, `longitude` meaning if `axes` is not included these values must be provided per point.
+
+However `axes` can also be provided by the user and with more values:
+
+```python
+"axes" : ["latitude", "longitude", "levelist", "step"]
+```
+
+In this case a point must contain a value for each axis.
+
+`inflate` determines the shape that will be swept along the trajectory, by default the value is `round` which corresponds to a circle in 2D and a sphere in 3D. The other value available is `box`. This sweeps an n-dimensional box along the trajectory depending on the specified `axes`.
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1,
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/166/167",
+ "number" : "1",
+ "step": "0",
+ "feature" : {
+ "type" : "trajectory",
+ "points" : [[-0.1, -0.1], [0, 0], [0.1, 0.1]],
+ "inflation" : [0.1, 0.2],
+ "inflate" : 'box',
+ "axes" :["latitude", "longitude"],
+ },
+}
+```
+
+This request returns the same as the first request, but sweeping a box of size `0.1` in the `latitude` direction and `0.2` in the `longitude` direction.
+
+
diff --git a/docs/Service/Features/vertical_profile.md b/docs/Service/Features/vertical_profile.md
new file mode 100644
index 000000000..a96b96eda
--- /dev/null
+++ b/docs/Service/Features/vertical_profile.md
@@ -0,0 +1,107 @@
+# Vertical Profile
+
+## Basic Example
+
+An example vertical profile requested via earthkit-data:
+
+```python
+import earthkit.data
+
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1, # Note: date must be within the last two days
+ "time" : "0000",
+ "levtype" : "pl",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "203/133",
+ "number" : "1",
+ "step" : "0",
+ "feature" : {
+ "type" : "verticalprofile",
+ "points": [[-9.10, 38.78]],
+ "axes": "levelist",
+ "range" : {
+ "start" : 0,
+ "end" : 1000,
+ }
+ },
+ "format": "covjson",
+}
+
+ds = earthkit.data.from_source("polytope", "ecmwf-mars", request, stream=False, address='polytope.ecmwf.int')
+```
+The following will return a vertical profile from yesterday's 00Z forecast with levels from `0` to `1000` including all levels available in between, for the parameters `164/167/169` at the point given. This data will be returned for each ensemble number requested.
+
+`"polytope"` refers to the underlying service being used to return the data. `"ecmwf-mars"` is the dataset we are looking to retrieve from. Setting `stream=False` returns all the requested data to us once it is available. `address` points to the endpoint for the polytope server.
+
+## Required Fields
+
+For a vertical profile two fields are required within the `feature` dictionary
+
+* `type`
+* `points`
+
+For a vertical profile `type` must be `verticalprofile`.
+
+`points` must be a nested list with two points corresponding to a latitude and a longitude.
+
+
+## Optional Fields
+
+`axes` refers to the axis on which to generate the vertical profile. In this case the vertical profile is generated across `levelist` based on the inputted `range`. In the vertical profile this field is optional as the default is assumed to be `levelist` if not given.
+
+`range` is an optional field within `feature`. It refers to the extent of the `axes` on which the vertical profile will be generated. In the above case where:
+
+```python
+ "axes": "levelist",
+ "range" : {
+ "start" : 0,
+ "end" : 1000,
+ }
+```
+
+A vertical profile across `levelist` will start at level `0` and end at level `1000` with all levels found in between being included. `range` can also contain `interval`.
+
+```python
+ "axes": "levelist",
+ "range" : {
+ "start" : 0,
+ "end" : 1000,
+ "interval" : 2,
+ }
+```
+In this case every second level will be returned if it exists.
+
+As `range` is an optional field it can be left out, however there is not a default value. Instead the user has to include the vertical profile `axes` in the main body of the request like below:
+
+```python
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1,
+ "time" : "0000",
+ "levtype" : "pl",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "203/133",
+ "number" : "1",
+ "step" : "0",
+ "levelist" : "0/to/1000",
+ "feature" : {
+ "type" : "verticalprofile",
+ "points": [[38.9, -9.1]],
+ "axes" : "levelist",
+ },
+}
+```
+
+This is equivalent to the first request presented.
+
+At least one of `range` or `levelist` must be included in the request, but not both. In this case an error will be provided telling the user that `levelist` is overspecified.
+
+Conversely at least one of `range` or `levelist` must be included.
+
diff --git a/docs/Service/Installation.md b/docs/Service/Installation.md
new file mode 100644
index 000000000..807fa748b
--- /dev/null
+++ b/docs/Service/Installation.md
@@ -0,0 +1,55 @@
+# Installation
+
+Install **earthkit-data** with python3 (>= 3.10) and ``pip`` as follows:
+
+
+ python3 -m pip install earthkit-data[polytope]
+
+To use covjson functionality also run:
+
+ python3 -m pip install earthkit-data[covjsonkit]
+
+To use any of the visualisations, also install earthkit-plots:
+
+ python3 -m pip install earthkit-plots
+
+Installing like this gives you a **minimal** package which can talk to Polytope. If you want to use more data types or remote services you need to install other optional features of earthkit-data, or just install all of them:
+
+ python3 -m pip install earthkit-data[all]
+
+For further details on earthkit-data installation you can visit this page: https://earthkit-data.readthedocs.io/en/latest/install.html.
+
+We recommend to create a conda environment for your earthkit installation. This can be done as follows:
+
+```
+envname=earthkit
+conda create -n $envname -c conda-forge -y python=3.10
+conda activate $envname
+
+python3 -m pip install earthkit-data[polytope]
+
+# To allow easy use with a jupyter notebook run the following
+python3 -m pip install ipykernel
+python3 -m ipykernel install --user --name=$envname
+```
+
+# Authentication
+
+To access ECMWF data you need an ECMWF account. This can be created https://www.ecmwf.int/. Once created, you can find your key at https://api.ecmwf.int/v1/key/.
+
+**DISCLAIMER**
+> *Polytope is currently available for users at the national meteorological services of ECMWF’s Member and Co-operating States.*
+
+Copy your API key into your home directory, in a file called `~/.polytopeapirc`. Ths file should have the following format:
+
+
+```
+{
+ "user_email" : "",
+ "user_key" : ""
+}
+```
+
+You should now be automatically authenticated when using Polytope feature extraction via earthkit-data.
+
+After following these steps, go to the Quick Start guide to begin making requests.
\ No newline at end of file
diff --git a/docs/Service/Overview.md b/docs/Service/Overview.md
new file mode 100644
index 000000000..6509be2bd
--- /dev/null
+++ b/docs/Service/Overview.md
@@ -0,0 +1,42 @@
+# Polytope Feature Extraction
+
+Polytope is a web service designed to provide efficient access to datacubes. Polytope's key feature is the ability to directly extract **features** from a datacube, as well as whole fields, without any intermediate copies.
+
+Features currently includes time-series, vertical profiles, a custom polygon region, bounding box or spatio-temporal trajectory.
+
+Extracting features directly offers two main advantages:
+
+- Reduced I/O usage when requesting data from large datacubes, which means less data downloaded.
+
+- Reduced post-processing needs for users after extraction, making the data more analysis-ready.
+
+
+
+
+
+
+
+## Polytope Feature Extraction vs Web MARS
+
+Feature extraction differs from Web-MARS by allowing users to request specific features rather than only global fields. However, Polytope does also allow users to request global fields by simply omitting the `feature` keyword from the request.
+
+Both Polytope and Web-MARS are integrated into the earthkit ecosystem allowing users to request and retrieve data using either service. Earthkit tools for mapping, plotting, regridding, and transforming are available for working with both whole fields and specific features.
+
+## Feature Extraction Client
+
+The recommended client for Polytope Feature Extraction is earthkit-data. A guide on how to install earthkit-data can be found here, a quick start user guide is also provided here. This allows users to quickly install earthkit-data and to begin making requests.
+
+For more in-depth information about the various features see the following pages:
+
+### Features
+ - Timeseries
+ - Vertical Profile
+ - Polygon
+ - Bounding Box
+ - Trajectory
+
+
+
+A set of example notebooks can also be found in the Examples page along with some examples of integration with other earthkit libraries.
+
+A Data Portfolio containing information on what data we provide is also available.
diff --git a/docs/Service/Quick_Start.md b/docs/Service/Quick_Start.md
new file mode 100644
index 000000000..b79bff87a
--- /dev/null
+++ b/docs/Service/Quick_Start.md
@@ -0,0 +1,72 @@
+# Quick Start
+
+Once a user has installed earthkit-data and has their credentials in place, you can make a simple request.
+
+An example of a time-series requested via earthkit-data:
+
+```python
+import earthkit.data
+
+request = {
+ "class": "od",
+ "stream" : "enfo",
+ "type" : "pf",
+ "date" : -1, # Note: date must be within the last two days
+ "time" : "0000",
+ "levtype" : "sfc",
+ "expver" : "0001",
+ "domain" : "g",
+ "param" : "164/167/169",
+ "number" : "1/to/50",
+ "feature" : {
+ "type" : "timeseries",
+ "points": [[-9.10, 38.78]],
+ "axes": "step",
+ "range" : {
+ "start" : 0,
+ "end" : 360,
+ }
+ },
+ "format": "covjson",
+}
+
+ds = earthkit.data.from_source("polytope", "ecmwf-mars", request, stream=False, address='polytope.ecmwf.int')
+```
+The following will return a timeseries starting yesterday at midnight with steps from `0` to `360` including all steps available in between, for the parameters `164/167/169` at the point given. This data will be returned for each ensemble number requested.
+
+`"polytope"` refers to the underlying service being used to return the data. `"ecmwf-mars"` is the dataset we are looking to retrieve from. Setting `stream=False` returns all the requested data to us once it is available. `address` points to the endpoint for the polytope server.
+
+To view the returned covjson run:
+
+```python
+ds._json()
+```
+
+To convert your covjson into an xarray the following can be done:
+
+```python
+ds.to_xarray()
+```
+
+The following visualisation can be created using the latest version of earthkit-plots.
+
+```python
+from earthkit.plots.interactive import Chart
+
+TIME_FREQUENCY = "6h"
+QUANTILES = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1]
+
+chart = Chart()
+chart.title(f"ECMWF ensemble meteogram")
+chart.box(ds, time_frequency=TIME_FREQUENCY, quantiles=QUANTILES)
+chart.line(ds,aggregation='mean', line_color='grey', time_frequency=TIME_FREQUENCY)
+chart.show(renderer="png") # Replace with chart.show() in an interactive session!
+```
+
+
+
+
+
+
+
+For more information about each feature see the Features page.
\ No newline at end of file
diff --git a/docs/images/polytope_feature.png b/docs/images/polytope_feature.png
new file mode 100644
index 000000000..4f6cfab6a
Binary files /dev/null and b/docs/images/polytope_feature.png differ
diff --git a/docs/images/timeseries_example.png b/docs/images/timeseries_example.png
new file mode 100644
index 000000000..a57ea904c
Binary files /dev/null and b/docs/images/timeseries_example.png differ
diff --git a/docs/images/timeseries_qs.png b/docs/images/timeseries_qs.png
new file mode 100644
index 000000000..1c95e9b57
Binary files /dev/null and b/docs/images/timeseries_qs.png differ
diff --git a/docs/index.md b/docs/index.md
index 285475da0..00677abc2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -37,20 +37,33 @@ Note that Polytope reads only the user-requested data, instead of whole fields.
!!! important
Note that Polytope reads only the user-requested data, instead of whole fields. Importantly, this implies a significant decrease of the I/O usage when reading data from a datacube.
+Polytope feature extraction consists of the algorithm itself, and the service that uses the algorithm to extract features from ECMWF datacubes. Details on the service can be found in the Polytope service overview, while details on the feature extraction algorithm can be found on the Polytope algorithm overview.
-See Overview for more information.
-
-To learn more about how to use Polytope, refer to the User Guide. In particular, see the Quickstart page for a step-by-step example of how to use the Polytope software.
-For a more in-depth explanation of how Polytope achieves its feature exctraction, refer to the Developer Guide.
+To learn more about how to use Polytope, refer to the Quickstart page. In particular, see the Quickstart page for a step-by-step example of how to use the Polytope software.
+For a more in-depth explanation of how Polytope achieves its feature extraction, refer to the Developer Guide.
!!! Warning
This project is BETA and will be experimental for the foreseeable future. Interfaces and functionality are likely to change. DO NOT use this software in any project/software that is operational.
+# Index
+
+### Service
+ * Overview
+ * Installation
+ * Quick Start
+ * Features
+ * Examples
+
+### Algorithm
+ * Overview
+ * User Guide
+ * Developer Guide
+
# License
*Polytope* is available under the open source [Apache License](http://www.apache.org/licenses/LICENSE-2.0).
- In applying this licence, ECMWF does not waive the privileges and immunities granted to it by virtue of its status as an intergovernmental organisation nor does it submit to any jurisdiction.
+ In applying this license, ECMWF does not waive the privileges and immunities granted to it by virtue of its status as an intergovernmental organisation nor does it submit to any jurisdiction.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 710d77926..c3e040380 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,2 +1,3 @@
jinja2>=3.1.3
-Markdown
\ No newline at end of file
+Markdown
+mkdocs-jupyter
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
index 52d84d14d..fd323459c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -11,19 +11,34 @@ markdown_extensions:
# - pymdownx.superfences
extra_css:
- extra.css
-nav:
+nav:
- Home: index.md
- - Overview:
- - Overview: Overview/Overview.md
- - Polytope at ECMWF: Overview/Polytope_at_ECMWF.md
- - User Guide:
- - Overview: User_Guide/Overview.md
- - Getting Started: User_Guide/Getting_started.md
- - Example: User_Guide/Example.md
+ - Service:
+ - Overview: Service/Overview.md
+ - Installation: Service/Installation.md
+ - Quick Start: Service/Quick_Start.md
+ - Features:
+ - Overview: Service/Features/feature.md
+ - Time Series: Service/Features/timeseries.md
+ - Vertical Profile: Service/Features/vertical_profile.md
+ - Polygon: Service/Features/polygon.md
+ - Bounding Box: Service/Features/boundingbox.md
+ - Trajectory: Service/Features/trajectory.md
+ - Examples: Service/Examples/examples.md
+ - Data Portfolio: Service/Data_Portfolio.md
+ - Algorithm:
+ - Overview: Algorithm/Overview/Overview.md
+ - Polytope at ECMWF: Algorithm/Overview/Polytope_at_ECMWF.md
+ - User Guide:
+ - Overview: Algorithm/User_Guide/Overview.md
+ - Getting Started: Algorithm/User_Guide/Getting_started.md
+ - Example: Algorithm/User_Guide/Example.md
- Developer Guide:
- - Overview: Developer_Guide/Overview.md
- - Datacube: Developer_Guide/Datacube.md
- - Slicer: Developer_Guide/Slicer.md
- - APIs: Developer_Guide/API.md
- # - API levels: Developer_Guide/API.md
- # - Mid-level Shapes: Developer_Guide/shapes.md
+ - Overview: Algorithm/Developer_Guide/Overview.md
+ - Datacube: Algorithm/Developer_Guide/Datacube.md
+ - Slicer: Algorithm/Developer_Guide/Slicer.md
+ - APIs:
+ Algorithm/Developer_Guide/API.md
+plugins:
+ - search
+ - mkdocs-jupyter
diff --git a/polytope/utility/exceptions.py b/polytope/utility/exceptions.py
deleted file mode 100644
index fb30f627d..000000000
--- a/polytope/utility/exceptions.py
+++ /dev/null
@@ -1,25 +0,0 @@
-class AxisOverdefinedError(KeyError):
- def __init__(self, axis):
- self.axis = axis
- self.message = (
- f"Axis {axis} is overdefined. You have used it in two or more input polytopes which"
- f"cannot form a union (because they span different axes)."
- )
-
-
-class AxisUnderdefinedError(KeyError):
- def __init__(self, axis):
- self.axis = axis
- self.message = f"Axis {axis} is underdefined. It does not appear in any input polytope."
-
-
-class AxisNotFoundError(KeyError):
- def __init__(self, axis):
- self.axis = axis
- self.message = f"Axis {axis} does not exist in the datacube."
-
-
-class UnsliceableShapeError(KeyError):
- def __init__(self, axis):
- self.axis = axis
- self.message = f"Higher-dimensional shape does not support unsliceable axis {axis.name}."
diff --git a/polytope/version.py b/polytope/version.py
deleted file mode 100644
index 382021f30..000000000
--- a/polytope/version.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = "1.0.6"
diff --git a/polytope/__init__.py b/polytope_feature/__init__.py
similarity index 100%
rename from polytope/__init__.py
rename to polytope_feature/__init__.py
diff --git a/polytope/datacube/__init__.py b/polytope_feature/datacube/__init__.py
similarity index 100%
rename from polytope/datacube/__init__.py
rename to polytope_feature/datacube/__init__.py
diff --git a/polytope/datacube/backends/__init__.py b/polytope_feature/datacube/backends/__init__.py
similarity index 100%
rename from polytope/datacube/backends/__init__.py
rename to polytope_feature/datacube/backends/__init__.py
diff --git a/polytope/datacube/backends/datacube.py b/polytope_feature/datacube/backends/datacube.py
similarity index 93%
rename from polytope/datacube/backends/datacube.py
rename to polytope_feature/datacube/backends/datacube.py
index 524bb5298..97a8c7c77 100644
--- a/polytope/datacube/backends/datacube.py
+++ b/polytope_feature/datacube/backends/datacube.py
@@ -1,6 +1,6 @@
import logging
from abc import ABC, abstractmethod
-from typing import Any
+from typing import Any, Dict
from ...utility.combinatorics import validate_axes
from ..datacube_axis import DatacubeAxis
@@ -31,9 +31,10 @@ def __init__(self, axis_options=None, compressed_axes_options=[]):
self.merged_axes = []
self.unwanted_path = {}
self.compressed_axes = compressed_axes_options
+ self.grid_md5_hash = None
@abstractmethod
- def get(self, requests: TensorIndexTree) -> Any:
+ def get(self, requests: TensorIndexTree, context: Dict) -> Any:
"""Return data given a set of request trees"""
@property
@@ -69,6 +70,7 @@ def _create_axes(self, name, values, transformation_type_key, transformation_opt
# TODO: do we use this?? This shouldn't work for a disk in lat/lon on a octahedral or other grid??
for compressed_grid_axis in transformation.compressed_grid_axes:
self.compressed_grid_axes.append(compressed_grid_axis)
+ self.grid_md5_hash = transformation.md5_hash
if len(final_axis_names) > 1:
self.coupled_axes.append(final_axis_names)
for axis in final_axis_names:
@@ -130,9 +132,10 @@ def get_indices(self, path: DatacubePath, axis, lower, upper, method=None):
"""
path = self.fit_path(path)
indexes = axis.find_indexes(path, self)
+
idx_between = axis.find_indices_between(indexes, lower, upper, self, method)
- logging.info(f"For axis {axis.name} between {lower} and {upper}, found indices {idx_between}")
+ logging.debug(f"For axis {axis.name} between {lower} and {upper}, found indices {idx_between}")
return idx_between
@@ -149,17 +152,19 @@ def remap_path(self, path: DatacubePath):
return path
@staticmethod
- def create(datacube, config={}, axis_options={}, compressed_axes_options=[], alternative_axes=[]):
+ def create(datacube, config={}, axis_options={}, compressed_axes_options=[], alternative_axes=[], context=None):
# TODO: get the configs as None for pre-determined value and change them to empty dictionary inside the function
if type(datacube).__name__ == "DataArray":
from .xarray import XArrayDatacube
- xadatacube = XArrayDatacube(datacube, axis_options, compressed_axes_options)
+ xadatacube = XArrayDatacube(datacube, axis_options, compressed_axes_options, context)
return xadatacube
if type(datacube).__name__ == "GribJump":
from .fdb import FDBDatacube
- fdbdatacube = FDBDatacube(datacube, config, axis_options, compressed_axes_options, alternative_axes)
+ fdbdatacube = FDBDatacube(
+ datacube, config, axis_options, compressed_axes_options, alternative_axes, context
+ )
return fdbdatacube
def check_branching_axes(self, request):
diff --git a/polytope/datacube/backends/fdb.py b/polytope_feature/datacube/backends/fdb.py
similarity index 88%
rename from polytope/datacube/backends/fdb.py
rename to polytope_feature/datacube/backends/fdb.py
index fd10a130b..bd60665af 100644
--- a/polytope/datacube/backends/fdb.py
+++ b/polytope_feature/datacube/backends/fdb.py
@@ -3,14 +3,19 @@
from copy import deepcopy
from itertools import product
+from ...utility.exceptions import BadGridError, BadRequestError
from ...utility.geometry import nearest_pt
from .datacube import Datacube, TensorIndexTree
class FDBDatacube(Datacube):
- def __init__(self, gj, config=None, axis_options=None, compressed_axes_options=[], alternative_axes=[]):
+ def __init__(
+ self, gj, config=None, axis_options=None, compressed_axes_options=[], alternative_axes=[], context=None
+ ):
if config is None:
config = {}
+ if context is None:
+ context = {}
super().__init__(axis_options, compressed_axes_options)
@@ -24,12 +29,22 @@ def __init__(self, gj, config=None, axis_options=None, compressed_axes_options=[
self.gj = gj
if len(alternative_axes) == 0:
- self.fdb_coordinates = self.gj.axes(partial_request)
+ logging.info("Find GribJump axes for %s", context)
+ self.fdb_coordinates = self.gj.axes(partial_request, ctx=context)
+ logging.info("Retrieved available GribJump axes for %s", context)
+ if len(self.fdb_coordinates) == 0:
+ raise BadRequestError(partial_request)
else:
self.fdb_coordinates = {}
for axis_config in alternative_axes:
self.fdb_coordinates[axis_config.axis_name] = axis_config.values
+ fdb_coordinates_copy = deepcopy(self.fdb_coordinates)
+ for axis, vals in fdb_coordinates_copy.items():
+ if len(vals) == 1:
+ if vals[0] == "":
+ self.fdb_coordinates.pop(axis)
+
logging.info("Axes returned from GribJump are: " + str(self.fdb_coordinates))
self.fdb_coordinates["values"] = []
@@ -77,8 +92,9 @@ def check_branching_axes(self, request):
for axis_name in axes_to_remove:
self._axes.pop(axis_name, None)
- def get(self, requests: TensorIndexTree):
- requests.pprint()
+ def get(self, requests: TensorIndexTree, context=None):
+ if context is None:
+ context = {}
if len(requests.children) == 0:
return requests
fdb_requests = []
@@ -104,12 +120,27 @@ def get(self, requests: TensorIndexTree):
uncompressed_request = {}
for i, key in enumerate(compressed_request[0].keys()):
uncompressed_request[key] = combi[i]
- complete_uncompressed_request = (uncompressed_request, compressed_request[1])
+ complete_uncompressed_request = (uncompressed_request, compressed_request[1], self.grid_md5_hash)
complete_list_complete_uncompressed_requests.append(complete_uncompressed_request)
complete_fdb_decoding_info.append(fdb_requests_decoding_info[j])
- logging.debug("The requests we give GribJump are: %s", complete_list_complete_uncompressed_requests)
- output_values = self.gj.extract(complete_list_complete_uncompressed_requests)
- logging.debug("GribJump outputs: %s", output_values)
+
+ if logging.root.level <= logging.DEBUG:
+ printed_list_to_gj = complete_list_complete_uncompressed_requests[::1000]
+ logging.debug("The requests we give GribJump are: %s", printed_list_to_gj)
+ logging.info("Requests given to GribJump extract for %s", context)
+ try:
+ output_values = self.gj.extract(complete_list_complete_uncompressed_requests, context)
+ except Exception as e:
+ if "BadValue: Grid hash mismatch" in str(e):
+ logging.info("Error is: %s", e)
+ raise BadGridError()
+ else:
+ raise e
+
+ logging.info("Requests extracted from GribJump for %s", context)
+ if logging.root.level <= logging.DEBUG:
+ printed_output_values = output_values[::1000]
+ logging.debug("GribJump outputs: %s", printed_output_values)
self.assign_fdb_output_to_nodes(output_values, complete_fdb_decoding_info)
def get_fdb_requests(
@@ -124,7 +155,7 @@ def get_fdb_requests(
# First when request node is root, go to its children
if requests.axis.name == "root":
- logging.info("Looking for data for the tree: %s", [leaf.flatten() for leaf in requests.leaves])
+ logging.debug("Looking for data for the tree")
for c in requests.children:
self.get_fdb_requests(c, fdb_requests, fdb_requests_decoding_info)
@@ -161,8 +192,8 @@ def remove_duplicates_in_request_ranges(self, fdb_node_ranges, current_start_idx
new_current_start_idx = []
for j, idx in enumerate(sub_lat_idxs):
if idx not in seen_indices:
- # TODO: need to remove it from the values in the corresponding tree node
- # TODO: need to read just the range we give to gj ... DONE?
+ # NOTE: need to remove it from the values in the corresponding tree node
+ # NOTE: need to read just the range we give to gj
original_fdb_node_range_vals.append(actual_fdb_node[0].values[j])
seen_indices.add(idx)
new_current_start_idx.append(idx)
@@ -187,8 +218,6 @@ def nearest_lat_lon_search(self, requests):
second_ax = requests.children[0].children[0].axis
- # TODO: actually, here we should not remap the nearest_pts, we should instead unmap the
- # found_latlon_pts and then remap them later once we have compared found_latlon_pts and nearest_pts
nearest_pts = [
[lat_val, second_ax._remap_val_to_axis_range(lon_val)]
for (lat_val, lon_val) in zip(
@@ -325,8 +354,6 @@ def sort_fdb_request_ranges(self, current_start_idx, lat_length, fdb_node_ranges
request_ranges_with_idx = list(enumerate(interm_request_ranges))
sorted_list = sorted(request_ranges_with_idx, key=lambda x: x[1][0])
original_indices, sorted_request_ranges = zip(*sorted_list)
- logging.debug("We sorted the request ranges into: %s", sorted_request_ranges)
- logging.debug("The sorted and unique leaf node ranges are: %s", new_fdb_node_ranges)
return (original_indices, sorted_request_ranges, new_fdb_node_ranges)
def datacube_natural_indexes(self, axis, subarray):
diff --git a/polytope/datacube/backends/mock.py b/polytope_feature/datacube/backends/mock.py
similarity index 94%
rename from polytope/datacube/backends/mock.py
rename to polytope_feature/datacube/backends/mock.py
index 1a48360eb..c23b80802 100644
--- a/polytope/datacube/backends/mock.py
+++ b/polytope_feature/datacube/backends/mock.py
@@ -24,10 +24,12 @@ def __init__(self, dimensions, compressed_axes_options=[]):
self.stride[k] = stride_cumulative
stride_cumulative *= self.dimensions[k]
- def get(self, requests: TensorIndexTree):
+ def get(self, requests: TensorIndexTree, context=None):
# Takes in a datacube and verifies the leaves of the tree are complete
# (ie it found values for all datacube axis)
+ if context is None:
+ context = {}
for r in requests.leaves:
path = r.flatten()
if len(path.items()) == len(self.dimensions.items()):
diff --git a/polytope/datacube/backends/xarray.py b/polytope_feature/datacube/backends/xarray.py
similarity index 95%
rename from polytope/datacube/backends/xarray.py
rename to polytope_feature/datacube/backends/xarray.py
index c01ea52bc..735c87e25 100644
--- a/polytope/datacube/backends/xarray.py
+++ b/polytope_feature/datacube/backends/xarray.py
@@ -9,7 +9,7 @@
class XArrayDatacube(Datacube):
"""Xarray arrays are labelled, axes can be defined as strings or integers (e.g. "time" or 0)."""
- def __init__(self, dataarray: xr.DataArray, axis_options=None, compressed_axes_options=[]):
+ def __init__(self, dataarray: xr.DataArray, axis_options=None, compressed_axes_options=[], context=None):
super().__init__(axis_options, compressed_axes_options)
if axis_options is None:
axis_options = {}
@@ -50,12 +50,14 @@ def __init__(self, dataarray: xr.DataArray, axis_options=None, compressed_axes_o
val = self._axes[name].type
self._check_and_add_axes(options, name, val)
- def get(self, requests, leaf_path=None, axis_counter=0):
+ def get(self, requests, context=None, leaf_path=None, axis_counter=0):
+ if context is None:
+ context = {}
if leaf_path is None:
leaf_path = {}
if requests.axis.name == "root":
for c in requests.children:
- self.get(c, leaf_path, axis_counter + 1)
+ self.get(c, context, leaf_path, axis_counter + 1)
else:
key_value_path = {requests.axis.name: requests.values}
ax = requests.axis
@@ -66,7 +68,7 @@ def get(self, requests, leaf_path=None, axis_counter=0):
if len(requests.children) != 0:
# We are not a leaf and we loop over
for c in requests.children:
- self.get(c, leaf_path, axis_counter + 1)
+ self.get(c, context, leaf_path, axis_counter + 1)
else:
if self.axis_counter != axis_counter:
requests.remove_branch()
diff --git a/polytope/datacube/datacube_axis.py b/polytope_feature/datacube/datacube_axis.py
similarity index 100%
rename from polytope/datacube/datacube_axis.py
rename to polytope_feature/datacube/datacube_axis.py
diff --git a/polytope/datacube/index_tree.proto b/polytope_feature/datacube/index_tree.proto
similarity index 100%
rename from polytope/datacube/index_tree.proto
rename to polytope_feature/datacube/index_tree.proto
diff --git a/polytope/datacube/index_tree_pb2.py b/polytope_feature/datacube/index_tree_pb2.py
similarity index 100%
rename from polytope/datacube/index_tree_pb2.py
rename to polytope_feature/datacube/index_tree_pb2.py
diff --git a/polytope/datacube/tensor_index_tree.py b/polytope_feature/datacube/tensor_index_tree.py
similarity index 99%
rename from polytope/datacube/tensor_index_tree.py
rename to polytope_feature/datacube/tensor_index_tree.py
index 5cb813b99..604aa7441 100644
--- a/polytope/datacube/tensor_index_tree.py
+++ b/polytope_feature/datacube/tensor_index_tree.py
@@ -105,6 +105,7 @@ def add_child(self, node):
def add_value(self, value):
new_values = list(self.values)
new_values.append(value)
+ new_values.sort()
self.values = tuple(new_values)
def create_child(self, axis, value, next_nodes):
diff --git a/polytope/datacube/transformations/__init__.py b/polytope_feature/datacube/transformations/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/__init__.py
rename to polytope_feature/datacube/transformations/__init__.py
diff --git a/polytope/datacube/transformations/datacube_cyclic/__init__.py b/polytope_feature/datacube/transformations/datacube_cyclic/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_cyclic/__init__.py
rename to polytope_feature/datacube/transformations/datacube_cyclic/__init__.py
diff --git a/polytope/datacube/transformations/datacube_cyclic/datacube_cyclic.py b/polytope_feature/datacube/transformations/datacube_cyclic/datacube_cyclic.py
similarity index 99%
rename from polytope/datacube/transformations/datacube_cyclic/datacube_cyclic.py
rename to polytope_feature/datacube/transformations/datacube_cyclic/datacube_cyclic.py
index b7e402eae..3373dd082 100644
--- a/polytope/datacube/transformations/datacube_cyclic/datacube_cyclic.py
+++ b/polytope_feature/datacube/transformations/datacube_cyclic/datacube_cyclic.py
@@ -1,7 +1,7 @@
import math
from copy import deepcopy
-from ....utility.combinatorics import unique
+from ....utility.list_tools import unique
from ..datacube_transformations import DatacubeAxisTransformation
diff --git a/polytope/datacube/transformations/datacube_mappers/__init__.py b/polytope_feature/datacube/transformations/datacube_mappers/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_mappers/__init__.py
rename to polytope_feature/datacube/transformations/datacube_mappers/__init__.py
diff --git a/polytope/datacube/transformations/datacube_mappers/datacube_mappers.py b/polytope_feature/datacube/transformations/datacube_mappers/datacube_mappers.py
similarity index 87%
rename from polytope/datacube/transformations/datacube_mappers/datacube_mappers.py
rename to polytope_feature/datacube/transformations/datacube_mappers/datacube_mappers.py
index f79d43de3..e28566e69 100644
--- a/polytope/datacube/transformations/datacube_mappers/datacube_mappers.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/datacube_mappers.py
@@ -13,19 +13,32 @@ def __init__(self, name, mapper_options):
self.grid_resolution = mapper_options.resolution
self.grid_axes = mapper_options.axes
self.local_area = []
+ self.md5_hash = None
+ if mapper_options.md5_hash is not None:
+ self.md5_hash = mapper_options.md5_hash
if mapper_options.local is not None:
self.local_area = mapper_options.local
+ self._axis_reversed = None
+ if mapper_options.axis_reversed is not None:
+ self._axis_reversed = mapper_options.axis_reversed
self.old_axis = name
self._final_transformation = self.generate_final_transformation()
self._final_mapped_axes = self._final_transformation._mapped_axes
self._axis_reversed = self._final_transformation._axis_reversed
self.compressed_grid_axes = self._final_transformation.compressed_grid_axes
+ self.md5_hash = self._final_transformation.md5_hash
def generate_final_transformation(self):
map_type = _type_to_datacube_mapper_lookup[self.grid_type]
- module = import_module("polytope.datacube.transformations.datacube_mappers.mapper_types." + self.grid_type)
+ module = import_module(
+ "polytope_feature.datacube.transformations.datacube_mappers.mapper_types." + self.grid_type
+ )
constructor = getattr(module, map_type)
- transformation = deepcopy(constructor(self.old_axis, self.grid_axes, self.grid_resolution, self.local_area))
+ transformation = deepcopy(
+ constructor(
+ self.old_axis, self.grid_axes, self.grid_resolution, self.md5_hash, self.local_area, self._axis_reversed
+ )
+ )
return transformation
def blocked_axes(self):
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/__init__.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/__init__.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/__init__.py
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/healpix.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/healpix.py
similarity index 88%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/healpix.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/healpix.py
index b198a157a..6fdf9d54a 100644
--- a/polytope/datacube/transformations/datacube_mappers/mapper_types/healpix.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/healpix.py
@@ -5,7 +5,7 @@
class HealpixGridMapper(DatacubeMapper):
- def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
+ def __init__(self, base_axis, mapped_axes, resolution, md5_hash=None, local_area=[], axis_reversed=None):
# TODO: if local area is not empty list, raise NotImplemented
self._mapped_axes = mapped_axes
self._base_axis = base_axis
@@ -13,6 +13,14 @@ def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
self._axis_reversed = {mapped_axes[0]: True, mapped_axes[1]: False}
self._first_axis_vals = self.first_axis_vals()
self.compressed_grid_axes = [self._mapped_axes[1]]
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(resolution, None)
+ if self._axis_reversed[mapped_axes[1]]:
+ raise NotImplementedError("Healpix grid with second axis in decreasing order is not supported")
+ if not self._axis_reversed[mapped_axes[0]]:
+ raise NotImplementedError("Healpix grid with first axis in increasing order is not supported")
def first_axis_vals(self):
rad2deg = 180 / math.pi
@@ -133,3 +141,7 @@ def unmap(self, first_val, second_val):
second_idx = self.second_axis_vals(first_val).index(second_val)
healpix_index = self.axes_idx_to_healpix_idx(first_idx, second_idx)
return healpix_index
+
+
+# md5 grid hash in form {resolution : hash}
+_md5_hash = {}
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/healpix_nested.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/healpix_nested.py
similarity index 92%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/healpix_nested.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/healpix_nested.py
index abbd01392..c71f59f5b 100644
--- a/polytope/datacube/transformations/datacube_mappers/mapper_types/healpix_nested.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/healpix_nested.py
@@ -5,7 +5,7 @@
class NestedHealpixGridMapper(DatacubeMapper):
- def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
+ def __init__(self, base_axis, mapped_axes, resolution, md5_hash=None, local_area=[], axis_reversed=None):
# TODO: if local area is not empty list, raise NotImplemented
self._mapped_axes = mapped_axes
self._base_axis = base_axis
@@ -17,6 +17,14 @@ def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
self.k = int(math.log2(self.Nside))
self.Npix = 12 * self.Nside * self.Nside
self.Ncap = (self.Nside * (self.Nside - 1)) << 1
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(resolution, None)
+ if self._axis_reversed[mapped_axes[1]]:
+ raise NotImplementedError("Healpix grid with second axis in decreasing order is not supported")
+ if not self._axis_reversed[mapped_axes[0]]:
+ raise NotImplementedError("Healpix grid with first axis in increasing order is not supported")
def first_axis_vals(self):
rad2deg = 180 / math.pi
@@ -211,3 +219,7 @@ def ring_to_nested(self, idx):
def int_sqrt(self, i):
return int(math.sqrt(i + 0.5))
+
+
+# md5 grid hash in form {resolution : hash}
+_md5_hash = {}
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/local_regular.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/local_regular.py
similarity index 69%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/local_regular.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/local_regular.py
index 40f86bfbd..df611250e 100644
--- a/polytope/datacube/transformations/datacube_mappers/mapper_types/local_regular.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/local_regular.py
@@ -4,7 +4,7 @@
class LocalRegularGridMapper(DatacubeMapper):
- def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
+ def __init__(self, base_axis, mapped_axes, resolution, md5_hash=None, local_area=[], axis_reversed=None):
# TODO: if local area is not empty list, raise NotImplemented
self._mapped_axes = mapped_axes
self._base_axis = base_axis
@@ -15,17 +15,38 @@ def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
if not isinstance(resolution, list):
self.first_resolution = resolution
self.second_resolution = resolution
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(resolution, None)
else:
self.first_resolution = resolution[0]
self.second_resolution = resolution[1]
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(tuple(resolution), None)
self._first_deg_increment = (local_area[1] - local_area[0]) / self.first_resolution
self._second_deg_increment = (local_area[3] - local_area[2]) / self.second_resolution
- self._axis_reversed = {mapped_axes[0]: True, mapped_axes[1]: False}
+ if axis_reversed is None:
+ self._axis_reversed = {mapped_axes[0]: False, mapped_axes[1]: False}
+ else:
+ assert set(axis_reversed.keys()) == set(mapped_axes)
+ self._axis_reversed = axis_reversed
self._first_axis_vals = self.first_axis_vals()
self.compressed_grid_axes = [self._mapped_axes[1]]
+ if self._axis_reversed[mapped_axes[1]]:
+ raise NotImplementedError("Local regular grid with second axis in decreasing order is not supported")
def first_axis_vals(self):
- first_ax_vals = [self._first_axis_max - i * self._first_deg_increment for i in range(self.first_resolution + 1)]
+ if self._axis_reversed[self._mapped_axes[0]]:
+ first_ax_vals = [
+ self._first_axis_max - i * self._first_deg_increment for i in range(self.first_resolution + 1)
+ ]
+ else:
+ first_ax_vals = [
+ self._first_axis_min + i * self._first_deg_increment for i in range(self.first_resolution + 1)
+ ]
return first_ax_vals
def map_first_axis(self, lower, upper):
@@ -68,3 +89,7 @@ def unmap(self, first_val, second_val):
second_idx = self.second_axis_vals(first_val).index(second_val)
final_index = self.axes_idx_to_regular_idx(first_idx, second_idx)
return final_index
+
+
+# md5 grid hash in form {resolution : hash}
+_md5_hash = {}
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/octahedral.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/octahedral.py
similarity index 99%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/octahedral.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/octahedral.py
index f48fca712..51c378786 100644
--- a/polytope/datacube/transformations/datacube_mappers/mapper_types/octahedral.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/octahedral.py
@@ -5,7 +5,7 @@
class OctahedralGridMapper(DatacubeMapper):
- def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
+ def __init__(self, base_axis, mapped_axes, resolution, md5_hash=None, local_area=[], axis_reversed=None):
# TODO: if local area is not empty list, raise NotImplemented
self._mapped_axes = mapped_axes
self._base_axis = base_axis
@@ -14,7 +14,15 @@ def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
self._first_idx_map = self.create_first_idx_map()
self._second_axis_spacing = {}
self._axis_reversed = {mapped_axes[0]: True, mapped_axes[1]: False}
+ if self._axis_reversed[mapped_axes[1]]:
+ raise NotImplementedError("Octahedral grid with second axis in decreasing order is not supported")
+ if not self._axis_reversed[mapped_axes[0]]:
+ raise NotImplementedError("Octahedral grid with first axis in increasing order is not supported")
self.compressed_grid_axes = [self._mapped_axes[1]]
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(resolution, None)
def gauss_first_guess(self):
i = 0
@@ -2750,3 +2758,9 @@ def unmap(self, first_val, second_val):
(first_idx, second_idx) = self.find_second_axis_idx(first_val, second_val)
octahedral_index = self.axes_idx_to_octahedral_idx(first_idx, second_idx)
return octahedral_index
+
+
+# md5 grid hash in form {resolution : hash}
+_md5_hash = {
+ 1280: "158db321ae8e773681eeb40e0a3d350f",
+}
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/reduced_ll.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/reduced_ll.py
similarity index 98%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/reduced_ll.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/reduced_ll.py
index 5a76f5d10..391e1bbe0 100644
--- a/polytope/datacube/transformations/datacube_mappers/mapper_types/reduced_ll.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/reduced_ll.py
@@ -4,7 +4,7 @@
class ReducedLatLonMapper(DatacubeMapper):
- def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
+ def __init__(self, base_axis, mapped_axes, resolution, md5_hash=None, local_area=[], axis_reversed=None):
# TODO: if local area is not empty list, raise NotImplemented
self._mapped_axes = mapped_axes
self._base_axis = base_axis
@@ -12,6 +12,14 @@ def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
self._axis_reversed = {mapped_axes[0]: False, mapped_axes[1]: False}
self._first_axis_vals = self.first_axis_vals()
self.compressed_grid_axes = [self._mapped_axes[1]]
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(resolution, None)
+ if self._axis_reversed[mapped_axes[1]]:
+ raise NotImplementedError("Reduced lat-lon grid with second axis in decreasing order is not supported")
+ if self._axis_reversed[mapped_axes[0]]:
+ raise NotImplementedError("Reduced lat-lon grid with first axis in decreasing order is not supported")
def first_axis_vals(self):
resolution = 180 / (self._resolution - 1)
@@ -1504,3 +1512,7 @@ def unmap(self, first_val, second_val):
second_idx = self.second_axis_vals(first_val).index(second_val)
reduced_ll_index = self.axes_idx_to_reduced_ll_idx(first_idx, second_idx)
return reduced_ll_index
+
+
+# md5 grid hash in form {resolution : hash}
+_md5_hash = {}
diff --git a/polytope/datacube/transformations/datacube_mappers/mapper_types/regular.py b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/regular.py
similarity index 70%
rename from polytope/datacube/transformations/datacube_mappers/mapper_types/regular.py
rename to polytope_feature/datacube/transformations/datacube_mappers/mapper_types/regular.py
index 3b40f77e4..f4dfb6486 100644
--- a/polytope/datacube/transformations/datacube_mappers/mapper_types/regular.py
+++ b/polytope_feature/datacube/transformations/datacube_mappers/mapper_types/regular.py
@@ -4,18 +4,31 @@
class RegularGridMapper(DatacubeMapper):
- def __init__(self, base_axis, mapped_axes, resolution, local_area=[]):
+ def __init__(self, base_axis, mapped_axes, resolution, md5_hash=None, local_area=[], axis_reversed=None):
# TODO: if local area is not empty list, raise NotImplemented
self._mapped_axes = mapped_axes
self._base_axis = base_axis
self._resolution = resolution
self.deg_increment = 90 / self._resolution
- self._axis_reversed = {mapped_axes[0]: True, mapped_axes[1]: False}
+ if axis_reversed is None:
+ self._axis_reversed = {mapped_axes[0]: True, mapped_axes[1]: False}
+ else:
+ assert set(axis_reversed.keys()) == set(mapped_axes)
+ self._axis_reversed = axis_reversed
self._first_axis_vals = self.first_axis_vals()
self.compressed_grid_axes = [self._mapped_axes[1]]
+ if md5_hash is not None:
+ self.md5_hash = md5_hash
+ else:
+ self.md5_hash = _md5_hash.get(resolution, None)
+ if self._axis_reversed[mapped_axes[1]]:
+ raise NotImplementedError("Regular grid with second axis in decreasing order is not supported")
def first_axis_vals(self):
- first_ax_vals = [90 - i * self.deg_increment for i in range(2 * self._resolution)]
+ if self._axis_reversed[self._mapped_axes[0]]:
+ first_ax_vals = [90 - i * self.deg_increment for i in range(2 * self._resolution)]
+ else:
+ first_ax_vals = [-90 + i * self.deg_increment for i in range(2 * self._resolution)]
return first_ax_vals
def map_first_axis(self, lower, upper):
@@ -56,3 +69,7 @@ def unmap(self, first_val, second_val):
second_idx = self.second_axis_vals(first_val).index(second_val)
final_index = self.axes_idx_to_regular_idx(first_idx, second_idx)
return final_index
+
+
+# md5 grid hash in form {resolution : hash}
+_md5_hash = {}
diff --git a/polytope/datacube/transformations/datacube_merger/__init__.py b/polytope_feature/datacube/transformations/datacube_merger/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_merger/__init__.py
rename to polytope_feature/datacube/transformations/datacube_merger/__init__.py
diff --git a/polytope/datacube/transformations/datacube_merger/datacube_merger.py b/polytope_feature/datacube/transformations/datacube_merger/datacube_merger.py
similarity index 97%
rename from polytope/datacube/transformations/datacube_merger/datacube_merger.py
rename to polytope_feature/datacube/transformations/datacube_merger/datacube_merger.py
index 717237a33..5b0516638 100644
--- a/polytope/datacube/transformations/datacube_merger/datacube_merger.py
+++ b/polytope_feature/datacube/transformations/datacube_merger/datacube_merger.py
@@ -93,6 +93,5 @@ def unmap_tree_node(self, node, unwanted_path):
if node.axis.name == self._first_axis:
(new_first_vals, new_second_vals) = self.unmerge(node.values)
node.values = new_first_vals
- # TODO: actually need to give the second axis of the transformation to get the interm axis
interm_node = node.add_node_layer_after(self._second_axis, new_second_vals)
return (interm_node, unwanted_path)
diff --git a/polytope/datacube/transformations/datacube_reverse/__init__.py b/polytope_feature/datacube/transformations/datacube_reverse/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_reverse/__init__.py
rename to polytope_feature/datacube/transformations/datacube_reverse/__init__.py
diff --git a/polytope/datacube/transformations/datacube_reverse/datacube_reverse.py b/polytope_feature/datacube/transformations/datacube_reverse/datacube_reverse.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_reverse/datacube_reverse.py
rename to polytope_feature/datacube/transformations/datacube_reverse/datacube_reverse.py
diff --git a/polytope/datacube/transformations/datacube_transformations.py b/polytope_feature/datacube/transformations/datacube_transformations.py
similarity index 94%
rename from polytope/datacube/transformations/datacube_transformations.py
rename to polytope_feature/datacube/transformations/datacube_transformations.py
index 34af22550..f174bf1aa 100644
--- a/polytope/datacube/transformations/datacube_transformations.py
+++ b/polytope_feature/datacube/transformations/datacube_transformations.py
@@ -12,9 +12,8 @@ def create_transform(name, transformation_type_key, transformation_options):
transformation_type = _type_to_datacube_transformation_lookup[transformation_type_key]
transformation_file_name = _type_to_transformation_file_lookup[transformation_type_key]
file_name = ".datacube_" + transformation_file_name
- module = import_module("polytope.datacube.transformations" + file_name + file_name)
+ module = import_module("polytope_feature.datacube.transformations" + file_name + file_name)
constructor = getattr(module, transformation_type)
- # transformation_type_option = transformation_options[transformation_type_key]
transformation_type_option = transformation_options
new_transformation = deepcopy(constructor(name, transformation_type_option))
diff --git a/polytope/datacube/transformations/datacube_type_change/__init__.py b/polytope_feature/datacube/transformations/datacube_type_change/__init__.py
similarity index 100%
rename from polytope/datacube/transformations/datacube_type_change/__init__.py
rename to polytope_feature/datacube/transformations/datacube_type_change/__init__.py
diff --git a/polytope/datacube/transformations/datacube_type_change/datacube_type_change.py b/polytope_feature/datacube/transformations/datacube_type_change/datacube_type_change.py
similarity index 95%
rename from polytope/datacube/transformations/datacube_type_change/datacube_type_change.py
rename to polytope_feature/datacube/transformations/datacube_type_change/datacube_type_change.py
index 5fb88ece1..e9fa287db 100644
--- a/polytope/datacube/transformations/datacube_type_change/datacube_type_change.py
+++ b/polytope_feature/datacube/transformations/datacube_type_change/datacube_type_change.py
@@ -15,7 +15,7 @@ def __init__(self, name, type_options):
def generate_final_transformation(self):
map_type = _type_to_datacube_type_change_lookup[self.new_type]
- module = import_module("polytope.datacube.transformations.datacube_type_change.datacube_type_change")
+ module = import_module("polytope_feature.datacube.transformations.datacube_type_change.datacube_type_change")
constructor = getattr(module, map_type)
transformation = deepcopy(constructor(self.name, self.new_type))
return transformation
diff --git a/polytope/datacube/tree_encoding.py b/polytope_feature/datacube/tree_encoding.py
similarity index 100%
rename from polytope/datacube/tree_encoding.py
rename to polytope_feature/datacube/tree_encoding.py
diff --git a/polytope/engine/__init__.py b/polytope_feature/engine/__init__.py
similarity index 100%
rename from polytope/engine/__init__.py
rename to polytope_feature/engine/__init__.py
diff --git a/polytope/engine/engine.py b/polytope_feature/engine/engine.py
similarity index 100%
rename from polytope/engine/engine.py
rename to polytope_feature/engine/engine.py
diff --git a/polytope/engine/hullslicer.py b/polytope_feature/engine/hullslicer.py
similarity index 84%
rename from polytope/engine/hullslicer.py
rename to polytope_feature/engine/hullslicer.py
index 767f89161..9a99682a6 100644
--- a/polytope/engine/hullslicer.py
+++ b/polytope_feature/engine/hullslicer.py
@@ -9,9 +9,10 @@
from ..datacube.datacube_axis import UnsliceableDatacubeAxis
from ..datacube.tensor_index_tree import TensorIndexTree
from ..shapes import ConvexPolytope
-from ..utility.combinatorics import argmax, argmin, group, tensor_product, unique
+from ..utility.combinatorics import group, tensor_product
from ..utility.exceptions import UnsliceableShapeError
from ..utility.geometry import lerp
+from ..utility.list_tools import argmax, argmin, unique
from .engine import Engine
@@ -110,7 +111,7 @@ def remap_values(self, ax, value):
def _build_sliceable_child(self, polytope, ax, node, datacube, values, next_nodes, slice_axis_idx):
for i, value in enumerate(values):
- if i == 0:
+ if i == 0 or ax.name not in self.compressed_axes:
fvalue = ax.to_float(value)
new_polytope = slice(polytope, ax.name, fvalue, slice_axis_idx)
remapped_val = self.remap_values(ax, value)
@@ -121,19 +122,8 @@ def _build_sliceable_child(self, polytope, ax, node, datacube, values, next_node
child["unsliced_polytopes"].add(new_polytope)
next_nodes.append(child)
else:
- if ax.name not in self.compressed_axes:
- fvalue = ax.to_float(value)
- new_polytope = slice(polytope, ax.name, fvalue, slice_axis_idx)
- remapped_val = self.remap_values(ax, value)
- (child, next_nodes) = node.create_child(ax, remapped_val, next_nodes)
- child["unsliced_polytopes"] = copy(node["unsliced_polytopes"])
- child["unsliced_polytopes"].remove(polytope)
- if new_polytope is not None:
- child["unsliced_polytopes"].add(new_polytope)
- next_nodes.append(child)
- else:
- remapped_val = self.remap_values(ax, value)
- child.add_value(remapped_val)
+ remapped_val = self.remap_values(ax, value)
+ child.add_value(remapped_val)
def _build_branch(self, ax, node, datacube, next_nodes):
if ax.name not in self.compressed_axes:
@@ -142,26 +132,23 @@ def _build_branch(self, ax, node, datacube, next_nodes):
for polytope in node["unsliced_polytopes"]:
if ax.name in polytope._axes:
right_unsliced_polytopes.append(polytope)
- # for polytope in node["unsliced_polytopes"]:
for i, polytope in enumerate(right_unsliced_polytopes):
node._parent = parent_node
- # if ax.name in polytope._axes:
- if True:
- lower, upper, slice_axis_idx = polytope.extents(ax.name)
- # here, first check if the axis is an unsliceable axis and directly build node if it is
- # NOTE: we should have already created the ax_is_unsliceable cache before
- if self.ax_is_unsliceable[ax.name]:
- self._build_unsliceable_child(polytope, ax, node, datacube, [lower], next_nodes, slice_axis_idx)
- else:
- values = self.find_values_between(polytope, ax, node, datacube, lower, upper)
- # NOTE: need to only remove the branches if the values are empty,
- # but only if there are no other possible children left in the tree that
- # we can append and if somehow this happens before and we need to remove, then what do we do??
- if i == len(right_unsliced_polytopes) - 1:
- # we have iterated all polytopes and we can now remove the node if we need to
- if len(values) == 0 and len(node.children) == 0:
- node.remove_branch()
- self._build_sliceable_child(polytope, ax, node, datacube, values, next_nodes, slice_axis_idx)
+ lower, upper, slice_axis_idx = polytope.extents(ax.name)
+ # here, first check if the axis is an unsliceable axis and directly build node if it is
+ # NOTE: we should have already created the ax_is_unsliceable cache before
+ if self.ax_is_unsliceable[ax.name]:
+ self._build_unsliceable_child(polytope, ax, node, datacube, [lower], next_nodes, slice_axis_idx)
+ else:
+ values = self.find_values_between(polytope, ax, node, datacube, lower, upper)
+ # NOTE: need to only remove the branches if the values are empty,
+ # but only if there are no other possible children left in the tree that
+ # we can append and if somehow this happens before and we need to remove, then what do we do??
+ if i == len(right_unsliced_polytopes) - 1:
+ # we have iterated all polytopes and we can now remove the node if we need to
+ if len(values) == 0 and len(node.children) == 0:
+ node.remove_branch()
+ self._build_sliceable_child(polytope, ax, node, datacube, values, next_nodes, slice_axis_idx)
else:
all_values = []
all_lowers = []
@@ -215,7 +202,6 @@ def remove_compressed_axis_in_union(self, polytopes):
for p in polytopes:
if p.is_in_union:
for axis in p.axes():
- # if axis in self.compressed_axes:
if axis == self.compressed_axes[-1]:
self.compressed_axes.remove(axis)
diff --git a/polytope/options.py b/polytope_feature/options.py
similarity index 87%
rename from polytope/options.py
rename to polytope_feature/options.py
index 6baf8bc73..2e88c9679 100644
--- a/polytope/options.py
+++ b/polytope_feature/options.py
@@ -1,8 +1,7 @@
-import argparse
from abc import ABC
from typing import Dict, List, Literal, Optional, Union
-from conflator import ConfigModel, Conflator
+from conflator import ConfigModel
from pydantic import ConfigDict
@@ -21,7 +20,9 @@ class MapperConfig(TransformationConfig):
type: str = ""
resolution: Union[int, List[int]] = 0
axes: List[str] = [""]
+ md5_hash: Optional[str] = None
local: Optional[List[float]] = None
+ axis_reversed: Optional[Dict[str, bool]] = None
class ReverseConfig(TransformationConfig):
@@ -66,9 +67,7 @@ class Config(ConfigModel):
class PolytopeOptions(ABC):
@staticmethod
def get_polytope_options(options):
- parser = argparse.ArgumentParser(allow_abbrev=False)
- conflator = Conflator(app_name="polytope", model=Config, cli=False, argparser=parser, **options)
- config_options = conflator.load()
+ config_options = Config.model_validate(options)
axis_config = config_options.axis_config
compressed_axes_config = config_options.compressed_axes_config
diff --git a/polytope/polytope.py b/polytope_feature/polytope.py
similarity index 79%
rename from polytope/polytope.py
rename to polytope_feature/polytope.py
index 271725471..f18f10c4d 100644
--- a/polytope/polytope.py
+++ b/polytope_feature/polytope.py
@@ -1,3 +1,4 @@
+import logging
from typing import List
from .options import PolytopeOptions
@@ -38,7 +39,7 @@ def __repr__(self):
class Polytope:
- def __init__(self, datacube, engine=None, options=None):
+ def __init__(self, datacube, engine=None, options=None, context=None):
from .datacube import Datacube
from .engine import Engine
@@ -47,7 +48,11 @@ def __init__(self, datacube, engine=None, options=None):
axis_options, compressed_axes_options, config, alternative_axes = PolytopeOptions.get_polytope_options(options)
- self.datacube = Datacube.create(datacube, config, axis_options, compressed_axes_options, alternative_axes)
+ self.context = context
+
+ self.datacube = Datacube.create(
+ datacube, config, axis_options, compressed_axes_options, alternative_axes, self.context
+ )
self.engine = engine if engine is not None else Engine.default()
self.time = 0
@@ -57,7 +62,10 @@ def slice(self, polytopes: List[ConvexPolytope]):
def retrieve(self, request: Request, method="standard"):
"""Higher-level API which takes a request and uses it to slice the datacube"""
+ logging.info("Starting request for %s ", self.context)
self.datacube.check_branching_axes(request)
request_tree = self.engine.extract(self.datacube, request.polytopes())
- self.datacube.get(request_tree)
+ logging.info("Created request tree for %s ", self.context)
+ self.datacube.get(request_tree, self.context)
+ logging.info("Retrieved data for %s ", self.context)
return request_tree
diff --git a/polytope/shapes.py b/polytope_feature/shapes.py
similarity index 97%
rename from polytope/shapes.py
rename to polytope_feature/shapes.py
index c3a45a6a7..8d6744d9b 100644
--- a/polytope/shapes.py
+++ b/polytope_feature/shapes.py
@@ -1,10 +1,13 @@
import copy
import math
+import warnings
from abc import ABC, abstractmethod
from typing import List
import tripy
+from .utility.list_tools import unique
+
"""
Shapes used for the constructive geometry API of Polytope
"""
@@ -26,7 +29,7 @@ class ConvexPolytope(Shape):
def __init__(self, axes, points, method=None, is_orthogonal=False):
self._axes = list(axes)
self.is_flat = False
- if len(self._axes) == 1:
+ if len(self._axes) == 1 and len(points) == 1:
self.is_flat = True
self.points = points
self.method = method
@@ -60,11 +63,13 @@ def polytope(self):
# This is the only shape which can slice on axes without a discretizer or interpolator
class Select(Shape):
- """Matches several discrete value"""
+ """Matches several discrete values"""
def __init__(self, axis, values, method=None):
self.axis = axis
- self.values = values
+ self.values = unique(values)
+ if len(self.values) != len(values):
+ warnings.warn("Duplicate request values were removed")
self.method = method
def axes(self):
diff --git a/polytope/utility/__init__.py b/polytope_feature/utility/__init__.py
similarity index 100%
rename from polytope/utility/__init__.py
rename to polytope_feature/utility/__init__.py
diff --git a/polytope/utility/combinatorics.py b/polytope_feature/utility/combinatorics.py
similarity index 83%
rename from polytope/utility/combinatorics.py
rename to polytope_feature/utility/combinatorics.py
index 629ebbec8..4040c40db 100644
--- a/polytope/utility/combinatorics.py
+++ b/polytope_feature/utility/combinatorics.py
@@ -46,19 +46,3 @@ def validate_axes(actual_axes, test_axes):
raise AxisNotFoundError(ax)
return True
-
-
-def unique(points):
- points.sort()
- points = [k for k, _ in itertools.groupby(points)]
- return points
-
-
-def argmin(points):
- amin = min(range(len(points)), key=points.__getitem__)
- return amin
-
-
-def argmax(points):
- amax = max(range(len(points)), key=points.__getitem__)
- return amax
diff --git a/polytope_feature/utility/exceptions.py b/polytope_feature/utility/exceptions.py
new file mode 100644
index 000000000..205b4688a
--- /dev/null
+++ b/polytope_feature/utility/exceptions.py
@@ -0,0 +1,40 @@
+class PolytopeError(Exception):
+ pass
+
+
+class BadRequestError(PolytopeError):
+ def __init__(self, pre_path):
+ self.pre_path = pre_path
+ self.message = f"No data for {pre_path} is available on the FDB."
+
+
+class AxisOverdefinedError(PolytopeError, KeyError):
+ def __init__(self, axis):
+ self.axis = axis
+ self.message = (
+ f"Axis {axis} is overdefined. You have used it in two or more input polytopes which"
+ f" cannot form a union (because they span different axes)."
+ )
+
+
+class AxisUnderdefinedError(PolytopeError, KeyError):
+ def __init__(self, axis):
+ self.axis = axis
+ self.message = f"Axis {axis} is underdefined. It does not appear in any input polytope."
+
+
+class AxisNotFoundError(PolytopeError, KeyError):
+ def __init__(self, axis):
+ self.axis = axis
+ self.message = f"Axis {axis} does not exist in the datacube."
+
+
+class UnsliceableShapeError(PolytopeError, KeyError):
+ def __init__(self, axis):
+ self.axis = axis
+ self.message = f"Higher-dimensional shape does not support unsliceable axis {axis.name}."
+
+
+class BadGridError(PolytopeError, ValueError):
+ def __init__(self):
+ self.message = "Data on this grid is not supported by Polytope."
diff --git a/polytope/utility/geometry.py b/polytope_feature/utility/geometry.py
similarity index 100%
rename from polytope/utility/geometry.py
rename to polytope_feature/utility/geometry.py
diff --git a/polytope/utility/list_tools.py b/polytope_feature/utility/list_tools.py
similarity index 57%
rename from polytope/utility/list_tools.py
rename to polytope_feature/utility/list_tools.py
index 2d18917c1..51b8c7fec 100644
--- a/polytope/utility/list_tools.py
+++ b/polytope_feature/utility/list_tools.py
@@ -1,3 +1,6 @@
+import itertools
+
+
def bisect_left_cmp(arr, val, cmp):
left = -1
r = len(arr)
@@ -20,3 +23,19 @@ def bisect_right_cmp(arr, val, cmp):
else:
r = e
return r
+
+
+def unique(points):
+ points.sort()
+ points = [k for k, _ in itertools.groupby(points)]
+ return points
+
+
+def argmin(points):
+ amin = min(range(len(points)), key=points.__getitem__)
+ return amin
+
+
+def argmax(points):
+ amax = max(range(len(points)), key=points.__getitem__)
+ return amax
diff --git a/polytope/utility/profiling.py b/polytope_feature/utility/profiling.py
similarity index 100%
rename from polytope/utility/profiling.py
rename to polytope_feature/utility/profiling.py
diff --git a/polytope_feature/version.py b/polytope_feature/version.py
new file mode 100644
index 000000000..f871089b4
--- /dev/null
+++ b/polytope_feature/version.py
@@ -0,0 +1 @@
+__version__ = "1.0.15"
diff --git a/readme.md b/readme.md
index a9142841b..4a6eed368 100644
--- a/readme.md
+++ b/readme.md
@@ -38,7 +38,7 @@ Polytope supports datacubes which have branching, non-uniform indexing, and even
Polytope is designed to enable extraction of arbitrary extraction of data from a datacube. Instead of the typical range-based bounding-box approach, Polytope can extract any shape of data from a datacube using a "polytope" (*n*-dimensional polygon) stencil.