diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index 5ab757b..c044b22 100644 --- a/covjsonkit/encoder/BoundingBox.py +++ b/covjsonkit/encoder/BoundingBox.py @@ -117,8 +117,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): - + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a MultiPoint (BoundingBox) CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -130,8 +130,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) - + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) logging.debug("The values returned from walking tree: %s", range_dict) # noqa: E501 logging.debug("The coordinates returned from walking tree: %s", coords) # noqa: E501 diff --git a/covjsonkit/encoder/Circle.py b/covjsonkit/encoder/Circle.py index 3dc6db5..4818140 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -112,10 +112,10 @@ def from_xarray(self, dataset): self.add_coverage(mars_metadata, coords, dv_dict) # Return the generated CoverageJSON - return self.covjsonå - - def from_polytope(self, result): + return self.covjson + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a MultiPoint (Circle) CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -127,7 +127,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) logging.debug("The values returned from walking tree: %s", range_dict) # noqa: E501 logging.debug("The coordinates returned from walking tree: %s", coords) # noqa: E501 diff --git a/covjsonkit/encoder/Frame.py b/covjsonkit/encoder/Frame.py index 6cb7755..8afbb4e 100644 --- a/covjsonkit/encoder/Frame.py +++ b/covjsonkit/encoder/Frame.py @@ -114,8 +114,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): - + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a MultiPoint (Frame) CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -127,7 +127,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) logging.debug("The values returned from walking tree: %s", range_dict) # noqa: E501 logging.debug("The coordinates returned from walking tree: %s", coords) # noqa: E501 diff --git a/covjsonkit/encoder/Grid.py b/covjsonkit/encoder/Grid.py index ecdd12e..d0de674 100644 --- a/covjsonkit/encoder/Grid.py +++ b/covjsonkit/encoder/Grid.py @@ -120,8 +120,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): - + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a Grid CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -133,8 +133,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) - + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) logging.debug("The values returned from walking tree: %s", range_dict) # noqa: E501 logging.debug("The coordinates returned from walking tree: %s", coords) # noqa: E501 diff --git a/covjsonkit/encoder/Path.py b/covjsonkit/encoder/Path.py index 049e4ae..ad9171c 100644 --- a/covjsonkit/encoder/Path.py +++ b/covjsonkit/encoder/Path.py @@ -114,8 +114,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): - + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a Trajectory (Path) CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -129,7 +129,7 @@ def from_polytope(self, result): fields["s"] = [] fields["l"] = [] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) if len(fields["l"]) == 0: fields["l"] = [0] diff --git a/covjsonkit/encoder/Position.py b/covjsonkit/encoder/Position.py index b659454..1cc8e18 100644 --- a/covjsonkit/encoder/Position.py +++ b/covjsonkit/encoder/Position.py @@ -62,7 +62,7 @@ def from_xarray(self, datasets): datasets (Union[xarray.Dataset, List[xarray.Dataset]]): An xarray dataset or a list of xarray datasets. Returns: - dict: The CoverageJSON representation of the coverageCollection. + dict: The CoverageJSON representation of the coverage collection. """ if not isinstance(datasets, list): datasets = [datasets] @@ -123,13 +123,15 @@ def from_xarray(self, datasets): return self.covjson - def from_polytope(self, result): - """ - Converts a Polytope result into an OGC CoverageJSON coverageCollection of type PointSeries + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a PointSeries (Position) CoverageJSON collection. + Args: - result (dict): The Polytope result containing the data to be converted. + result: The polytope ``TensorIndexTree`` containing the data to be converted. + date_key: Tree axis name to treat as the time dimension + (``"date"`` for forecasts, ``"hdate"`` for hindcast/reforecast). Returns: - dict: The CoverageJSON representation of the coverageCollection. + dict: The CoverageJSON representation of the coverage collection. """ coords = {} mars_metadata = {} @@ -144,7 +146,7 @@ def from_polytope(self, result): start = time.time() logging.debug("Tree walking starts at: %s", start) # noqa: E501 - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) end = time.time() delta = end - start logging.debug("Tree walking ends at: %s", end) # noqa: E501 diff --git a/covjsonkit/encoder/Shapefile.py b/covjsonkit/encoder/Shapefile.py index 0741219..cd83d5a 100644 --- a/covjsonkit/encoder/Shapefile.py +++ b/covjsonkit/encoder/Shapefile.py @@ -114,8 +114,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): - + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a MultiPoint (Shapefile) CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -127,7 +127,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) logging.debug("The values returned from walking tree: %s", range_dict) # noqa: E501 logging.debug("The coordinates returned from walking tree: %s", coords) # noqa: E501 diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 32eb98a..310f630 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -62,7 +62,7 @@ def from_xarray(self, datasets): datasets (Union[xarray.Dataset, List[xarray.Dataset]]): An xarray dataset or a list of xarray datasets. Returns: - dict: The CoverageJSON representation of the coverageCollection. + dict: The CoverageJSON representation of the coverage collection. """ if not isinstance(datasets, list): datasets = [datasets] @@ -123,13 +123,15 @@ def from_xarray(self, datasets): return self.covjson - def from_polytope(self, result): - """ - Converts a Polytope result into an OGC CoverageJSON coverageCollection of type PointSeries + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a PointSeries CoverageJSON collection. + Args: - result (dict): The Polytope result containing the data to be converted. + result: The polytope ``TensorIndexTree`` containing the data to be converted. + date_key: Tree axis name to treat as the time dimension + (``"date"`` for forecasts, ``"hdate"`` for hindcast/reforecast). Returns: - dict: The CoverageJSON representation of the coverageCollection. + dict: The CoverageJSON representation of the coverage collection. """ coords = {} mars_metadata = {} @@ -144,7 +146,7 @@ def from_polytope(self, result): start = time.time() logging.debug("Tree walking starts at: %s", start) # noqa: E501 - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) end = time.time() delta = end - start logging.debug("Tree walking ends at: %s", end) # noqa: E501 diff --git a/covjsonkit/encoder/VerticalProfile.py b/covjsonkit/encoder/VerticalProfile.py index 6f298a2..0dd2db7 100644 --- a/covjsonkit/encoder/VerticalProfile.py +++ b/covjsonkit/encoder/VerticalProfile.py @@ -121,7 +121,8 @@ def from_xarray(self, datasets): return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a VerticalProfile CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -135,7 +136,7 @@ def from_polytope(self, result): start = time.time() logging.debug("Tree walking starts at: %s", start) # noqa: E501 - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) end = time.time() delta = end - start logging.debug("Tree walking ends at: %s", end) # noqa: E501 diff --git a/covjsonkit/encoder/Wkt.py b/covjsonkit/encoder/Wkt.py index 75526c4..940848d 100644 --- a/covjsonkit/encoder/Wkt.py +++ b/covjsonkit/encoder/Wkt.py @@ -118,8 +118,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): - + def from_polytope(self, result, date_key: str = "date") -> dict: + """Encode a polytope ``TensorIndexTree`` result into a MultiPoint (Wkt/Polygon) CoverageJSON collection.""" coords = {} mars_metadata = {} range_dict = {} @@ -131,7 +131,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key=date_key) logging.debug("The values returned from walking tree: %s", range_dict) # noqa: E501 logging.debug("The coordinates returned from walking tree: %s", coords) # noqa: E501 diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index b4bcf84..2968dc6 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from abc import ABC, abstractmethod +from typing import Any import orjson import pandas as pd @@ -149,12 +152,29 @@ def get_json(self): # self.covjson = self.pydantic_coverage.model_dump_json(exclude_none=True, indent=4) return orjson.dumps(self.covjson) - def walk_tree(self, tree, fields, coords, mars_metadata, range_dict): + def walk_tree( + self, + tree, + fields: dict[str, Any], + coords: dict[str, dict[str, list]], + mars_metadata: dict[str, Any], + range_dict: dict[tuple, list], + date_key: str = "date", + ) -> None: + """Walk the polytope result tree, extracting data into fields, coords, and range_dict. + + ``date_key`` controls which tree axis is treated as the time dimension + (e.g. ``"date"`` for forecasts, ``"hdate"`` for hindcast/reforecast data). + Any other axis with the default name falls through to ``mars_metadata`` + instead. Regardless of ``date_key``, values are always stored under + ``fields["dates"]``. + """ + def create_composite_key(date, level, num, para, s): return (date, level, num, para, s) def handle_non_leaf_node(child): - non_leaf_axes = ["latitude", "longitude", "param", "date"] + non_leaf_axes = ["latitude", "longitude", "param", date_key] if child.axis.name not in non_leaf_axes: mars_metadata[child.axis.name] = child.values[0] @@ -165,7 +185,7 @@ def handle_specific_axes(child): return child.values if child.axis.name == "param": return child.values - if child.axis.name in ["date", "time"]: + if child.axis.name in [date_key, "time"]: dates = [f"{date}Z" for date in child.values] mars_metadata["Forecast date"] = str(child.values[0]) for date in dates: @@ -202,7 +222,7 @@ def append_composite_coords(dates, tree_values, lat, coords): fields["l"].extend(result) elif child.axis.name == "param": fields["param"] = result - elif child.axis.name in ["date", "time"]: + elif child.axis.name in [date_key, "time"]: fields["dates"].extend(result) elif child.axis.name == "number": fields["number"] = result @@ -211,7 +231,7 @@ def append_composite_coords(dates, tree_values, lat, coords): if "s" in fields: fields["s"].extend(result) - self.walk_tree(child, fields, coords, mars_metadata, range_dict) + self.walk_tree(child, fields, coords, mars_metadata, range_dict, date_key=date_key) else: tree.values = [float(val) for val in tree.values] if all(val is None for val in tree.result): @@ -529,5 +549,14 @@ def from_xarray(self, dataset): pass @abstractmethod - def from_polytope(self, result): + def from_polytope(self, result, date_key: str = "date") -> dict: pass + + def from_polytope_reforecast(self, result) -> dict: + """Encode reforecast/reanalysis data that uses ``"hdate"`` as the time axis. + + Delegates to :meth:`from_polytope` with ``date_key="hdate"``. + Each hdate produces a separate coverage; steps within a single + hdate become that coverage's t-axis values. + """ + return self.from_polytope(result, date_key="hdate") diff --git a/covjsonkit/version.py b/covjsonkit/version.py index ddc77a8..699eb88 100644 --- a/covjsonkit/version.py +++ b/covjsonkit/version.py @@ -1 +1 @@ -__version__ = "0.2.15" +__version__ = "0.2.16" diff --git a/pyproject.toml b/pyproject.toml index c8d7aff..2f1c487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ markers = ["data: uses test data (deselect with '-m \"not data\"')",] [project] name = "covjsonkit" -version = "0.2.15" +version = "0.2.16" dependencies = [ "pandas<3", "orjson", @@ -23,3 +23,7 @@ geo = [ "rasterio", "shapely", ] +tests = [ + "pytest", + "polytope-python", +] diff --git a/requirements.txt b/requirements.txt index 6c4b775..7f4887f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ covjson-pydantic conflator scipy pre-commit +polytope-python diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..79e3744 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,123 @@ +import numpy as np +from polytope_feature.datacube.datacube_axis import IntDatacubeAxis +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +# -- Shared constants for reforecast tests -- + +REFORECAST_METADATA_BASE = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, +} + +COMPOSITE_TWO_POINTS_XYZ = { + "dataType": "tuple", + "coordinates": ["x", "y", "z"], + "values": [[48.0, 11.0, 0], [50.0, 12.0, 0]], +} + + +# -- Tree-building helpers -- + + +def node(name, values): + """Create a TensorIndexTree node with the given axis name and values.""" + ax = IntDatacubeAxis() + ax.name = name + return TensorIndexTree(axis=ax, values=tuple(values)) + + +def chain(*nodes): + """Link nodes sequentially via add_child(), return the root.""" + for a, b in zip(nodes, nodes[1:]): + a.add_child(b) + return nodes[0] + + +def tip(tree): + """Walk to the deepest single-child descendant (the 'tip' of a linear chain).""" + while tree.children: + tree = tree.children[0] + return tree + + +def make_leaf(lon, result): + """Create a longitude leaf node with result data.""" + leaf = node("longitude", (lon,)) + leaf.result = [np.float64(r) for r in result] + return leaf + + +def make_point(lat, lon, result): + """Create a latitude->longitude(leaf) subtree for a single spatial point.""" + lat_n = node("latitude", (lat,)) + lat_n.add_child(make_leaf(lon, result)) + return lat_n + + +def forecast_tree(points, param="167", step=(0,), date=np.datetime64("2025-01-01T00:00:00")): + """Build a standard forecast TensorIndexTree with the given spatial points. + + Args: + points: list of (lat, lon, result_list) tuples, passed to make_point(). + param: MARS parameter code. + step: tuple of step values. + date: forecast date. + """ + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (date,)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", (param,)), + node("step", step), + node("stream", ("oper",)), + node("type", ("fc",)), + ) + parent = tip(tree) + for lat, lon, result in points: + parent.add_child(make_point(lat, lon, result)) + return tree + + +def reforecast_branch(hdate, points, param="167", step=(0,)): + """Build a reforecast branch rooted at an hdate node. + + Attaches spatial points at the leaf. Caller is responsible for + grafting this onto a root tree via ``tip(root).add_child(branch)``. + """ + branch = chain( + node("hdate", (hdate,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", (param,)), + node("step", step), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + parent = tip(branch) + for lat, lon, result in points: + parent.add_child(make_point(lat, lon, result)) + return branch + + +def reforecast_tree(branches, date=np.datetime64("2024-03-01")): + """Build a reforecast tree with class=ce root, attaching pre-built hdate branches.""" + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (date,)), + ) + root = tip(tree) + for b in branches: + root.add_child(b) + return tree diff --git a/tests/data/test_timeseries_xyz_coverage.json b/tests/data/test_timeseries_xyz_coverage.json index 3e0718d..cca640b 100644 --- a/tests/data/test_timeseries_xyz_coverage.json +++ b/tests/data/test_timeseries_xyz_coverage.json @@ -348,4 +348,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/data/test_verticalprofile_coverage.json b/tests/data/test_verticalprofile_coverage.json index ed71891..a918dcc 100644 --- a/tests/data/test_verticalprofile_coverage.json +++ b/tests/data/test_verticalprofile_coverage.json @@ -569,4 +569,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/data/test_verticalprofile_xyz_coverage.json b/tests/data/test_verticalprofile_xyz_coverage.json index 28b7f2f..5196b9a 100644 --- a/tests/data/test_verticalprofile_xyz_coverage.json +++ b/tests/data/test_verticalprofile_xyz_coverage.json @@ -569,4 +569,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/test_encoder_bounding_box_from_polytope.py b/tests/test_encoder_bounding_box_from_polytope.py new file mode 100644 index 0000000..0739a54 --- /dev/null +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -0,0 +1,213 @@ +import numpy as np +from conftest import ( + chain, + forecast_tree, + make_point, + node, + reforecast_branch, + reforecast_tree, + tip, +) +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +# BoundingBox uses lat/lon/levelist coordinates (not x/y/z) and its +# reforecast metadata intentionally omits "step" (step varies per coverage). +COMPOSITE_TWO_POINTS = { + "dataType": "tuple", + "coordinates": ["latitude", "longitude", "levelist"], + "values": [[48.0, 11.0, 0], [50.0, 12.0, 0]], +} + +EXPECTED_REFORECAST_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "stream": "efcl", + "type": "sfo", + "number": 0, +} + +TWO_POINTS = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + + +class TestBoundingBoxFromPolytope: + def test_single_date_single_step_two_points(self): + tree = forecast_tree(TWO_POINTS) + covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-01-01T00:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "fc", + "number": 0, + } + + def test_two_dates_two_steps_two_points(self): + # 2 dates × 2 steps = 4 coverages + tree = chain(TensorIndexTree(), node("class", ("od",))) + cls = tip(tree) + + for date_val, vals in [ + (np.datetime64("2025-01-01T00:00:00"), [[264.9, 270.1], [265.1, 271.3]]), + (np.datetime64("2025-01-02T00:00:00"), [[266.0, 272.0], [267.0, 273.0]]), + ]: + branch = chain( + node("date", (date_val,)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0, 6)), + node("stream", ("oper",)), + node("type", ("fc",)), + ) + fc = tip(branch) + fc.add_child(make_point(48.0, 11.0, vals[0])) + fc.add_child(make_point(50.0, 12.0, vals[1])) + cls.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree) + + shared_metadata = { + "class": "od", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "stream": "oper", + "type": "fc", + "number": 0, + } + + expected = [ + ("2025-01-01T00:00:00Z", 0, [264.9, 265.1]), + ("2025-01-01T00:00:00Z", 6, [270.1, 271.3]), + ("2025-01-02T00:00:00Z", 0, [266.0, 267.0]), + ("2025-01-02T00:00:00Z", 6, [272.0, 273.0]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (date, step, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": [date]}, + "composite": COMPOSITE_TWO_POINTS, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == {**shared_metadata, "Forecast date": date, "step": step} + + +class TestBoundingBoxFromPolytopeReforecast: + def test_reforecast_single_hdate_two_points(self): + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-07-14T06:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + **EXPECTED_REFORECAST_METADATA, + "Forecast date": "2025-07-14T06:00:00Z", + "step": 0, + } + + def test_reforecast_two_hdates_two_points(self): + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), [(48.0, 11.0, [266.0]), (50.0, 12.0, [267.0])]), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope_reforecast(tree) + + expected = [ + ("2025-07-14T06:00:00Z", [264.9, 265.1]), + ("2025-07-15T06:00:00Z", [266.0, 267.0]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (fc_date, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": [fc_date]}, + "composite": COMPOSITE_TWO_POINTS, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == { + **EXPECTED_REFORECAST_METADATA, + "Forecast date": fc_date, + "step": 0, + } + + def test_reforecast_single_hdate_two_steps_two_points(self): + tree = reforecast_tree( + [ + reforecast_branch( + np.datetime64("2025-07-14T06:00:00"), + [(48.0, 11.0, [264.9, 270.1]), (50.0, 12.0, [265.1, 271.3])], + step=(0, 6), + ), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope_reforecast(tree) + + expected = [ + (0, [264.9, 265.1]), + (6, [270.1, 271.3]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (step, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-07-14T06:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == { + **EXPECTED_REFORECAST_METADATA, + "Forecast date": "2025-07-14T06:00:00Z", + "step": step, + } diff --git a/tests/test_encoder_circle_from_polytope.py b/tests/test_encoder_circle_from_polytope.py new file mode 100644 index 0000000..1baceb0 --- /dev/null +++ b/tests/test_encoder_circle_from_polytope.py @@ -0,0 +1,114 @@ +import numpy as np +from conftest import ( + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) + +from covjsonkit.api import Covjsonkit + +THREE_POINTS_COMPOSITE = { + "dataType": "tuple", + "coordinates": ["latitude", "longitude", "levelist"], + "values": [ + [48.0, 11.0, 0], + [49.0, 11.5, 0], + [50.0, 12.0, 0], + ], +} + +THREE_POINTS = [(48.0, 11.0, [264.9]), (49.0, 11.5, [265.5]), (50.0, 12.0, [266.1])] + + +class TestCircleFromPolytope: + def test_single_date_single_step_three_points(self): + tree = forecast_tree(THREE_POINTS) + covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-01-01T00:00:00Z"]}, + "composite": THREE_POINTS_COMPOSITE, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [3], + "axisNames": ["2t"], + "values": [264.9, 265.5, 266.1], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "fc", + "number": 0, + } + + +class TestCircleFromPolytopeReforecast: + def test_reforecast_single_hdate_three_points(self): + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), THREE_POINTS), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-07-14T06:00:00Z"]}, + "composite": THREE_POINTS_COMPOSITE, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [3], + "axisNames": ["2t"], + "values": [264.9, 265.5, 266.1], + } + } + + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **REFORECAST_METADATA_BASE} + + def test_reforecast_two_hdates_three_points(self): + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), THREE_POINTS), + reforecast_branch( + np.datetime64("2025-07-15T06:00:00"), + [(48.0, 11.0, [270.0]), (49.0, 11.5, [271.0]), (50.0, 12.0, [272.0])], + ), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T06:00:00Z"], [264.9, 265.5, 266.1], "2025-07-14T06:00:00Z"), + (["2025-07-15T06:00:00Z"], [270.0, 271.0, 272.0], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t_vals, range_vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": t_vals}, + "composite": THREE_POINTS_COMPOSITE, + } + assert cov["ranges"]["2t"]["values"] == range_vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **REFORECAST_METADATA_BASE} diff --git a/tests/test_encoder_frame_from_polytope.py b/tests/test_encoder_frame_from_polytope.py new file mode 100644 index 0000000..0330dc4 --- /dev/null +++ b/tests/test_encoder_frame_from_polytope.py @@ -0,0 +1,108 @@ +import numpy as np +from conftest import ( + COMPOSITE_TWO_POINTS_XYZ, + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) + +from covjsonkit.api import Covjsonkit + + +class TestFrameFromPolytope: + def test_single_date_single_step_two_points(self): + tree = forecast_tree([(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])]) + covjson = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-01-01T00:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "fc", + "number": 0, + } + + +class TestFrameFromPolytopeReforecast: + def test_reforecast_single_hdate_two_points(self): + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-07-14T06:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + **REFORECAST_METADATA_BASE, + "Forecast date": "2025-07-14T06:00:00Z", + } + + def test_reforecast_two_hdates_two_points(self): + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), [(48.0, 11.0, [266.0]), (50.0, 12.0, [267.0])]), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope_reforecast(tree) + + expected = [ + ("2025-07-14T06:00:00Z", [264.9, 265.1]), + ("2025-07-15T06:00:00Z", [266.0, 267.0]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (fc_date, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": [fc_date]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == { + **REFORECAST_METADATA_BASE, + "Forecast date": fc_date, + } diff --git a/tests/test_encoder_grid_from_polytope.py b/tests/test_encoder_grid_from_polytope.py new file mode 100644 index 0000000..146f3ec --- /dev/null +++ b/tests/test_encoder_grid_from_polytope.py @@ -0,0 +1,190 @@ +import numpy as np +from conftest import ( + REFORECAST_METADATA_BASE, + chain, + make_point, + node, + reforecast_branch, + reforecast_tree, + tip, +) +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +GRID_2X2_AXES = { + "t": {"values": [0]}, + "latitude": {"values": [48.0, 50.0]}, + "longitude": {"values": [11.0, 12.0]}, + "levelist": {"values": [0]}, +} + +GRID_2X2_RANGES = { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [1, 1, 2, 2], + "axisNames": ["t", "levelist", "latitude", "longitude"], + "values": [264.9, 265.1, 266.3, 267.5], + } +} + +GRID_2X2_POINTS = [ + (48.0, 11.0, [264.9]), + (48.0, 12.0, [265.1]), + (50.0, 11.0, [266.3]), + (50.0, 12.0, [267.5]), +] + + +class TestGridFromPolytope: + """Tests for Grid encoder's from_polytope method.""" + + def test_2x2_grid(self): + """2x2 grid: 2 latitudes, 2 longitudes, param 167 (2t), step 0.""" + # Grid uses type="an" (analysis), not type="fc" (forecast), + # so we build the tree inline instead of using forecast_tree(). + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2025-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("oper",)), + node("type", ("an",)), + ) + parent = tip(tree) + for lat, lon, vals in GRID_2X2_POINTS: + parent.add_child(make_point(lat, lon, vals)) + + covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "Grid" + + # Collection-level referencing + assert covjson["referencing"] == [ + { + "coordinates": ["latitude", "longitude", "levelist"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + } + ] + + # Collection-level parameters + assert "2t" in covjson["parameters"] + assert covjson["parameters"]["2t"]["type"] == "Parameter" + assert covjson["parameters"]["2t"]["observedProperty"] == { + "id": "2t", + "label": {"en": "2 metre temperature"}, + } + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == GRID_2X2_AXES + assert cov["ranges"] == GRID_2X2_RANGES + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "an", + "number": 0, + } + + def test_1x1_grid(self): + """Edge case: single-point grid -> shape [1,1,1,1].""" + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2025-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("oper",)), + node("type", ("an",)), + make_point(48.0, 11.0, [264.9]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": [0]}, + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [0]}, + } + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [1, 1, 1, 1], + "axisNames": ["t", "levelist", "latitude", "longitude"], + "values": [264.9], + } + } + + +class TestGridFromPolytopeReforecast: + """Tests for Grid encoder's from_polytope_reforecast method.""" + + def test_reforecast_single_hdate_2x2_grid(self): + """Single hdate with 2x2 grid -> 1 Grid coverage.""" + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), GRID_2X2_POINTS), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope_reforecast(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "Grid" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == GRID_2X2_AXES + assert cov["ranges"] == GRID_2X2_RANGES + assert cov["mars:metadata"] == { + **REFORECAST_METADATA_BASE, + "Forecast date": "2025-07-14T06:00:00Z", + } + + def test_reforecast_two_hdates_2x2_grid(self): + """Two hdates each with 2x2 grid -> 2 Grid coverages.""" + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), GRID_2X2_POINTS), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), GRID_2X2_POINTS), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope_reforecast(tree) + + expected = [ + "2025-07-14T06:00:00Z", + "2025-07-15T06:00:00Z", + ] + assert len(covjson["coverages"]) == len(expected) + for cov, fc_date in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == GRID_2X2_AXES + assert cov["ranges"] == GRID_2X2_RANGES + assert cov["mars:metadata"] == { + **REFORECAST_METADATA_BASE, + "Forecast date": fc_date, + } diff --git a/tests/test_encoder_path_from_polytope.py b/tests/test_encoder_path_from_polytope.py new file mode 100644 index 0000000..7f5e389 --- /dev/null +++ b/tests/test_encoder_path_from_polytope.py @@ -0,0 +1,138 @@ +import numpy as np +from conftest import ( + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) + +from covjsonkit.api import Covjsonkit + +TWO_POINTS = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + + +class TestPathFromPolytope: + """Tests for Path (Trajectory) encoder's from_polytope method.""" + + def test_two_points_single_step(self): + """2 points along a path, single step -> 1 Trajectory coverage.""" + points = [(48.0, 11.0, [264.9]), (49.0, 12.0, [265.1])] + tree = forecast_tree(points) + covjson = Covjsonkit().encode("CoverageCollection", "Path").from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "Trajectory" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "composite": { + "dataType": "tuple", + "coordinates": ["t", "x", "y", "z"], + "values": [[0, 48.0, 11.0, 0], [0, 49.0, 12.0, 0]], + } + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "fc", + "number": 0, + } + + # Collection-level referencing + assert covjson["referencing"] == [ + { + "coordinates": ["t", "x", "y", "z"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + } + ] + + # Collection-level parameters + assert "2t" in covjson["parameters"] + assert covjson["parameters"]["2t"]["type"] == "Parameter" + assert covjson["parameters"]["2t"]["observedProperty"]["id"] == "2t" + + +class TestPathFromPolytopeReforecast: + """Tests for Path (Trajectory) encoder's from_polytope_reforecast method.""" + + EXPECTED_AXES = { + "composite": { + "dataType": "tuple", + "coordinates": ["t", "x", "y", "z"], + "values": [[0, 48.0, 11.0, 0], [0, 50.0, 12.0, 0]], + } + } + + EXPECTED_RANGES = { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + def test_reforecast_single_hdate_two_points(self): + """Single hdate with 2 path points -> 1 Trajectory coverage.""" + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS), + ] + ) + covjson = Covjsonkit().encode("CoverageCollection", "Path").from_polytope_reforecast(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "Trajectory" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == self.EXPECTED_AXES + assert cov["ranges"] == self.EXPECTED_RANGES + assert cov["mars:metadata"] == { + **REFORECAST_METADATA_BASE, + "Forecast date": "2025-07-14T06:00:00Z", + } + + def test_reforecast_two_hdates_two_points(self): + """Two hdates each with 2 path points -> 2 Trajectory coverages.""" + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), TWO_POINTS), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Path").from_polytope_reforecast(tree) + + expected = [ + "2025-07-14T06:00:00Z", + "2025-07-15T06:00:00Z", + ] + assert len(covjson["coverages"]) == len(expected) + for cov, fc_date in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == self.EXPECTED_AXES + assert cov["ranges"] == self.EXPECTED_RANGES + assert cov["mars:metadata"] == {**REFORECAST_METADATA_BASE, "Forecast date": fc_date} diff --git a/tests/test_encoder_position_from_polytope.py b/tests/test_encoder_position_from_polytope.py new file mode 100644 index 0000000..3d33a11 --- /dev/null +++ b/tests/test_encoder_position_from_polytope.py @@ -0,0 +1,195 @@ +import numpy as np +from conftest import forecast_tree, reforecast_branch, reforecast_tree + +from covjsonkit.api import Covjsonkit + + +class TestPositionFromPolytope: + """Tests for Position (PointSeries) encoder's from_polytope method.""" + + def test_single_point_two_steps(self): + """1 point, 2 steps -> 1 coverage with t=[step0, step6].""" + tree = forecast_tree([(48.0, 11.0, [264.9, 263.8])], step=(0, 6)) + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "PointSeries" + + # Referencing + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["latitude", "longitude", "levelist"] + assert ref["system"]["type"] == "GeographicCRS" + + # Parameters + assert "2t" in covjson["parameters"] + assert covjson["parameters"]["2t"]["type"] == "Parameter" + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"]}, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 263.8], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "stream": "oper", + "type": "fc", + "number": 0, + } + + def test_two_points_two_steps(self): + """2 points, 2 steps -> 2 coverages (one per point).""" + tree = forecast_tree( + [(48.0, 11.0, [264.9, 263.8]), (50.0, 13.0, [265.1, 264.2])], + step=(0, 6), + ) + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree) + + shared_metadata = { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "stream": "oper", + "type": "fc", + "number": 0, + } + + expected = [ + (48.0, 11.0, [264.9, 263.8]), + (50.0, 13.0, [265.1, 264.2]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"]}, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == shared_metadata + + def test_single_step(self): + """Edge case: 1 step -> shape [1], single t-value.""" + tree = forecast_tree([(48.0, 11.0, [264.9])], step=(0,)) + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-01-01T00:00:00Z"]}, + } + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [1], + "axisNames": ["2t"], + "values": [264.9], + } + } + + +class TestPositionFromPolytopeReforecast: + """Tests for Position encoder's from_polytope_reforecast method.""" + + def test_reforecast_single_hdate_two_points(self): + """1 hdate, 2 points -> 2 coverages (1 per point).""" + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope_reforecast(tree) + + shared_metadata = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "Forecast date": "2025-07-14T06:00:00Z", + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "stream": "efcl", + "type": "sfo", + "number": 0, + } + + expected = [ + (48.0, 11.0, [264.9]), + (50.0, 12.0, [265.1]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T06:00:00Z"]}, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == shared_metadata + + def test_reforecast_two_hdates_two_points(self): + """2 hdates x 2 points -> 4 coverages.""" + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])]), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), [(48.0, 11.0, [266.0]), (50.0, 12.0, [267.0])]), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope_reforecast(tree) + + shared_metadata = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "stream": "efcl", + "type": "sfo", + "number": 0, + } + + expected = [ + (48.0, 11.0, ["2025-07-14T06:00:00Z"], [264.9], "2025-07-14T06:00:00Z"), + (48.0, 11.0, ["2025-07-15T06:00:00Z"], [266.0], "2025-07-15T06:00:00Z"), + (50.0, 12.0, ["2025-07-14T06:00:00Z"], [265.1], "2025-07-14T06:00:00Z"), + (50.0, 12.0, ["2025-07-15T06:00:00Z"], [267.0], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [0]}, + "t": {"values": t}, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == {**shared_metadata, "Forecast date": fc_date} diff --git a/tests/test_encoder_shapefile_from_polytope.py b/tests/test_encoder_shapefile_from_polytope.py new file mode 100644 index 0000000..611d7f1 --- /dev/null +++ b/tests/test_encoder_shapefile_from_polytope.py @@ -0,0 +1,102 @@ +import numpy as np +from conftest import ( + COMPOSITE_TWO_POINTS_XYZ, + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) + +from covjsonkit.api import Covjsonkit + + +class TestShapefileFromPolytope: + def test_single_date_single_step_two_points(self): + tree = forecast_tree([(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])]) + covjson = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-01-01T00:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "fc", + "number": 0, + } + + +class TestShapefileFromPolytopeReforecast: + def test_reforecast_single_hdate_two_points(self): + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-07-14T06:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **REFORECAST_METADATA_BASE} + + def test_reforecast_two_hdates_two_points(self): + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), [(48.0, 11.0, [266.0]), (50.0, 12.0, [267.0])]), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T06:00:00Z"], [264.9, 265.1], "2025-07-14T06:00:00Z"), + (["2025-07-15T06:00:00Z"], [266.0, 267.0], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t_vals, range_vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": t_vals}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + assert cov["ranges"]["2t"]["values"] == range_vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **REFORECAST_METADATA_BASE} diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py new file mode 100644 index 0000000..4d8fc51 --- /dev/null +++ b/tests/test_encoder_time_series_from_polytope.py @@ -0,0 +1,455 @@ +import numpy as np +from conftest import chain, make_leaf, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +# Axis ordering for hdate reforecast (between hdate and latitude in the tree) +HDATE_SUFFIX = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023",)), + ("step", (6,)), + ("stream", ("efcl",)), + ("type", ("sfo",)), +] + + +def hdate_branch(hdate, lat, lon, result): + """hdate → [HDATE_SUFFIX axes] → lat → lon(leaf). Single-point branch.""" + return chain( + node("hdate", (hdate,)), + *[node(n, v) for n, v in HDATE_SUFFIX], + make_point(lat, lon, result), + ) + + +EXPECTED_HDATE_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "model": "lisflood", + "origin": "ecmf", + "stream": "efcl", + "type": "sfo", + "number": 0, + "levelist": 0, +} + + +class TestTimeseriesFromPolytope: + def test_standard_forecast_single_point(self): + # od/oper/fc/sfc, 1 point, param 167 (2t), steps 0 and 6 + leaf = make_leaf(11.0, [264.931, 263.831]) + + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2025-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0, 6)), + node("stream", ("oper",)), + node("type", ("fc",)), + node("latitude", (48.0,)), + leaf, + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"]}, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.931, 263.831], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "stream": "oper", + "type": "fc", + "number": 0, + "levelist": 0, + } + + def test_standard_forecast_multiple_coverages(self): + # ce/efas/fc/sfc flood forecast: 2 dates × 2 steps × 2 points → 4 coverages + tree = chain(TensorIndexTree(), node("class", ("ce",))) + cls = tip(tree) + + for date_val, point_vals in [ + (np.datetime64("2026-01-01T00:00:00"), [[12.5, 19.3], [8.7, 14.1]]), + (np.datetime64("2026-01-01T12:00:00"), [[15.8, 22.6], [10.2, 16.9]]), + ]: + branch = chain( + node("date", (date_val,)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("model", ("lisflood",)), + node("origin", ("ecmf",)), + node("param", ("240023",)), + node("step", (6, 30)), + node("stream", ("efas",)), + node("type", ("fc",)), + ) + fc = tip(branch) + fc.add_child(make_point(51.5, 6.5, point_vals[0])) + fc.add_child(make_point(52.0, 7.0, point_vals[1])) + cls.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + shared_metadata = { + "class": "ce", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "model": "lisflood", + "origin": "ecmf", + "stream": "efas", + "type": "fc", + "number": 0, + "levelist": 0, + } + + expected = [ + (51.5, 6.5, ["2026-01-01T06:00:00Z", "2026-01-02T06:00:00Z"], [12.5, 19.3], "2026-01-01T00:00:00Z"), + (51.5, 6.5, ["2026-01-01T18:00:00Z", "2026-01-02T18:00:00Z"], [15.8, 22.6], "2026-01-01T12:00:00Z"), + (52.0, 7.0, ["2026-01-01T06:00:00Z", "2026-01-02T06:00:00Z"], [8.7, 14.1], "2026-01-01T00:00:00Z"), + (52.0, 7.0, ["2026-01-01T18:00:00Z", "2026-01-02T18:00:00Z"], [10.2, 16.9], "2026-01-01T12:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, t, vals, date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [0]}, + "t": {"values": t}, + } + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {**shared_metadata, "Forecast date": date} + + def test_multiple_params(self): + # 1 date, 2 params (167 = 2t, 168 = 2d), 1 step, 1 point → 1 coverage with both params + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2025-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", ("167", "168")), + node("step", (0,)), + node("stream", ("oper",)), + node("type", ("fc",)), + make_point(48.0, 11.0, [264.9, 250.1]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-01-01T00:00:00Z"]}, + } + assert cov["ranges"] == { + "2t": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["2t"], "values": [264.9]}, + "2d": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["2d"], "values": [250.1]}, + } + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "stream": "oper", + "type": "fc", + "number": 0, + "levelist": 0, + } + + +class TestTimeseriesFromPolytopeReforecast: + def test_single_point(self): + # 1 hdate (with time pre-merged by polytope-mars), 1 point + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + # t = hdate(2025-07-14T06:00) + step(6h) = 2025-07-14T12:00:00Z + assert cov["domain"]["axes"] == { + "latitude": {"values": [51.5]}, + "longitude": {"values": [6.5]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z"]}, + } + + assert cov["ranges"] == { + "dis06": { + "type": "NdArray", + "dataType": "float", + "shape": [1], + "axisNames": ["dis06"], + "values": [42.17], + } + } + + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_multiple_times(self): + # 2 hdate values (pre-merged times from same day), 1 point → 2 coverages + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + date.add_child(hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) + date.add_child(hdate_branch(np.datetime64("2025-07-14T12:00:00"), 51.5, 6.5, [55.30])) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), + (["2025-07-14T18:00:00Z"], [55.30], "2025-07-14T12:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"]["t"]["values"] == t + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} + + def test_multiple_hdates(self): + # 2 hdates (different days), 1 point → 2 coverages + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + date.add_child(hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) + date.add_child(hdate_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [55.30])) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), + (["2025-07-15T12:00:00Z"], [55.30], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"]["t"]["values"] == t + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} + + def test_two_points(self): + # 1 hdate, 2 points → 2 coverages (one per point) + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in HDATE_SUFFIX], + ) + sfo = tip(tree) + sfo.add_child(make_point(51.5, 6.5, [42.17])) + sfo.add_child(make_point(52.0, 7.0, [38.91])) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (51.5, 6.5, [42.17]), + (52.0, 7.0, [38.91]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z"]}, + } + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_two_points_two_times(self): + # 2 hdate values × 2 points → 4 coverages (point × hdate) + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + + for hdate_val, vals in [ + (np.datetime64("2025-07-14T06:00:00"), [42.17, 38.91]), + (np.datetime64("2025-07-14T12:00:00"), [55.30, 49.62]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + *[node(n, v) for n, v in HDATE_SUFFIX], + ) + sfo = tip(branch) + sfo.add_child(make_point(51.5, 6.5, [vals[0]])) + sfo.add_child(make_point(52.0, 7.0, [vals[1]])) + date.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (51.5, ["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), + (51.5, ["2025-07-14T18:00:00Z"], [55.30], "2025-07-14T12:00:00Z"), + (52.0, ["2025-07-14T12:00:00Z"], [38.91], "2025-07-14T06:00:00Z"), + (52.0, ["2025-07-14T18:00:00Z"], [49.62], "2025-07-14T12:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"]["latitude"]["values"] == [lat] + assert cov["domain"]["axes"]["t"]["values"] == t + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} + + def test_multiple_steps(self): + """Single hdate, two steps (6h, 12h), single point → 1 coverage with 2 t-values.""" + suffix = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023",)), + ("step", (6, 12)), + ("stream", ("efcl",)), + ("type", ("sfo",)), + ] + + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in suffix], + make_point(51.5, 6.5, [42.17, 55.30]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [51.5]}, + "longitude": {"values": [6.5]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"]}, + } + assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_multiple_params(self): + """Two parameters in a single hdate subtree.""" + suffix = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023", "231002")), + ("step", (6,)), + ("stream", ("efcl",)), + ("type", ("sfo",)), + ] + + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in suffix], + make_point(51.5, 6.5, [42.17, 99.5]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [51.5]}, + "longitude": {"values": [6.5]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z"]}, + } + assert cov["ranges"] == { + "dis06": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["dis06"], "values": [42.17]}, + "rowe": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["rowe"], "values": [99.5]}, + } + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_multiple_hdates_and_steps(self): + """4 hdates × 2 steps (6, 12) × 1 point → 4 coverages, each with 2 t-values.""" + suffix = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023",)), + ("step", (6, 12)), + ("stream", ("efcl",)), + ("type", ("sfo",)), + ] + + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + for hdate_val, vals in [ + (np.datetime64("2025-07-14T06:00:00"), [10.0, 20.0]), + (np.datetime64("2025-07-14T12:00:00"), [30.0, 40.0]), + (np.datetime64("2025-07-15T06:00:00"), [50.0, 60.0]), + (np.datetime64("2025-07-15T12:00:00"), [70.0, 80.0]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + *[node(n, v) for n, v in suffix], + make_point(51.5, 6.5, vals), + ) + date.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"], [10.0, 20.0], "2025-07-14T06:00:00Z"), + (["2025-07-14T18:00:00Z", "2025-07-15T00:00:00Z"], [30.0, 40.0], "2025-07-14T12:00:00Z"), + (["2025-07-15T12:00:00Z", "2025-07-15T18:00:00Z"], [50.0, 60.0], "2025-07-15T06:00:00Z"), + (["2025-07-15T18:00:00Z", "2025-07-16T00:00:00Z"], [70.0, 80.0], "2025-07-15T12:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"]["t"]["values"] == t + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} diff --git a/tests/test_encoder_vertical_profile_from_polytope.py b/tests/test_encoder_vertical_profile_from_polytope.py new file mode 100644 index 0000000..fde5c4e --- /dev/null +++ b/tests/test_encoder_vertical_profile_from_polytope.py @@ -0,0 +1,304 @@ +import numpy as np +from conftest import chain, make_leaf, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + + +class TestVerticalProfileFromPolytope: + """Tests for VerticalProfile encoder's from_polytope method.""" + + def _build_vp_tree(self, param="130", levels_values=None, step=0, lat=48.0, lon=11.0): + """Build a vertical-profile tree. + + The tree has one levelist node whose values tuple contains ALL requested + pressure levels. The leaf result array is ordered + [level0_val, level1_val, ...]. + """ + if levels_values is None: + levels_values = {1000: 290.1, 850: 280.2, 500: 250.3} + + levels = tuple(levels_values.keys()) + result = [levels_values[lv] for lv in levels] + + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2025-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("pl",)), + node("param", (param,)), + node("step", (step,)), + node("stream", ("oper",)), + node("type", ("an",)), + node("levelist", levels), + make_point(lat, lon, result), + ) + return tree + + def test_single_point_three_levels(self): + """1 point, 3 pressure levels, param 130 (t), step 0.""" + tree = self._build_vp_tree() + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "VerticalProfile" + + # Referencing (folded from removed test_referencing) + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["latitude", "longitude", "levelist"] + assert ref["system"]["type"] == "GeographicCRS" + + # Parameters (folded from removed test_parameters_block) + assert "t" in covjson["parameters"] + assert covjson["parameters"]["t"]["type"] == "Parameter" + assert "Temperature" in covjson["parameters"]["t"]["observedProperty"]["label"]["en"] + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [1000, 850, 500]}, + "t": {"values": ["2025-01-01T00:00:00Z"]}, + } + + assert cov["ranges"] == { + "t": { + "type": "NdArray", + "dataType": "float", + "shape": [3], + "axisNames": ["levelist"], + "values": [290.1, 280.2, 250.3], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "pl", + "step": 0, + "stream": "oper", + "type": "an", + "levelist": 1000, + "number": 0, + } + + def test_two_points_two_levels(self): + """2 spatial points, 2 levels → 2 coverages (one per point).""" + levels = (1000, 500) + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2025-06-15T12:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("pl",)), + node("param", ("130",)), + node("step", (0,)), + node("stream", ("oper",)), + node("type", ("an",)), + node("levelist", levels), + ) + lev_node = tip(tree) + lev_node.add_child(make_point(48.0, 11.0, [290.1, 250.3])) + lev_node.add_child(make_point(50.0, 13.0, [288.5, 248.7])) + + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree) + + shared_metadata = { + "class": "od", + "Forecast date": "2025-06-15T12:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "pl", + "step": 0, + "stream": "oper", + "type": "an", + "levelist": 1000, + "number": 0, + } + + expected = [ + (48.0, 11.0, [290.1, 250.3]), + (50.0, 13.0, [288.5, 248.7]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [1000, 500]}, + "t": {"values": ["2025-06-15T12:00:00Z"]}, + } + assert cov["ranges"] == { + "t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["levelist"], + "values": vals, + } + } + assert cov["mars:metadata"] == shared_metadata + + def test_step_offset(self): + """Step=6 should shift the t coordinate by 6 hours.""" + tree = self._build_vp_tree(step=6, levels_values={1000: 290.0}) + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [1000]}, + "t": {"values": ["2025-01-01T06:00:00Z"]}, + } + + assert cov["ranges"] == { + "t": { + "type": "NdArray", + "dataType": "float", + "shape": [1], + "axisNames": ["levelist"], + "values": [290.0], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "pl", + "step": 6, + "stream": "oper", + "type": "an", + "levelist": 1000, + "number": 0, + } + + +class TestVerticalProfileFromPolytopeReforecast: + """Tests for VerticalProfile encoder's from_polytope_reforecast method.""" + + REFORECAST_SUFFIX = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("pl",)), + ("param", ("130",)), + ("step", (6,)), + ("stream", ("efcl",)), + ("type", ("sfo",)), + ] + + EXPECTED_REFORECAST_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "pl", + "step": 6, + "stream": "efcl", + "type": "sfo", + "levelist": 1000, + "number": 0, + } + + def test_reforecast_single_hdate_three_levels(self): + """1 hdate, 3 pressure levels, 1 point → 1 coverage.""" + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in self.REFORECAST_SUFFIX], + node("levelist", (1000, 850, 500)), + node("latitude", (48.0,)), + make_leaf(11.0, [290.1, 280.2, 250.3]), + ) + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope_reforecast(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "VerticalProfile" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [1000, 850, 500]}, + "t": {"values": ["2025-07-14T12:00:00Z"]}, + } + + assert cov["ranges"] == { + "t": { + "type": "NdArray", + "dataType": "float", + "shape": [3], + "axisNames": ["levelist"], + "values": [290.1, 280.2, 250.3], + } + } + + assert cov["mars:metadata"] == { + "Forecast date": "2025-07-14T06:00:00Z", + **self.EXPECTED_REFORECAST_METADATA, + } + + def test_reforecast_two_hdates_three_levels(self): + """2 hdates, 3 levels, 1 point → 2 coverages (one per hdate).""" + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + ) + date_node = tip(tree) + + for hdate_val, vals in [ + (np.datetime64("2025-07-14T06:00:00"), [290.1, 280.2, 250.3]), + (np.datetime64("2025-07-15T06:00:00"), [291.0, 281.0, 251.0]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + *[node(n, v) for n, v in self.REFORECAST_SUFFIX], + node("levelist", (1000, 850, 500)), + node("latitude", (48.0,)), + make_leaf(11.0, vals), + ) + date_node.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope_reforecast(tree) + + expected = [ + ("2025-07-14T12:00:00Z", [290.1, 280.2, 250.3], "2025-07-14T06:00:00Z"), + ("2025-07-15T12:00:00Z", [291.0, 281.0, 251.0], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [48.0]}, + "longitude": {"values": [11.0]}, + "levelist": {"values": [1000, 850, 500]}, + "t": {"values": [t]}, + } + assert cov["ranges"] == { + "t": { + "type": "NdArray", + "dataType": "float", + "shape": [3], + "axisNames": ["levelist"], + "values": vals, + } + } + assert cov["mars:metadata"] == { + "Forecast date": fc_date, + **self.EXPECTED_REFORECAST_METADATA, + } diff --git a/tests/test_encoder_wkt_from_polytope.py b/tests/test_encoder_wkt_from_polytope.py new file mode 100644 index 0000000..0862f3f --- /dev/null +++ b/tests/test_encoder_wkt_from_polytope.py @@ -0,0 +1,105 @@ +import numpy as np +from conftest import ( + COMPOSITE_TWO_POINTS_XYZ, + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) + +from covjsonkit.api import Covjsonkit + + +class TestWktFromPolytope: + def test_single_date_single_step_two_points(self): + tree = forecast_tree([(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])]) + covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-01-01T00:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + "class": "od", + "Forecast date": "2025-01-01T00:00:00Z", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "step": 0, + "stream": "oper", + "type": "fc", + "number": 0, + } + + +class TestWktFromPolytopeReforecast: + def test_reforecast_single_hdate_two_points(self): + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "t": {"values": ["2025-07-14T06:00:00Z"]}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + "Forecast date": "2025-07-14T06:00:00Z", + **REFORECAST_METADATA_BASE, + } + + def test_reforecast_two_hdates_two_points(self): + points = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])] + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), points), + reforecast_branch(np.datetime64("2025-07-15T06:00:00"), [(48.0, 11.0, [266.0]), (50.0, 12.0, [267.0])]), + ] + ) + + covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T06:00:00Z"], [264.9, 265.1], "2025-07-14T06:00:00Z"), + (["2025-07-15T06:00:00Z"], [266.0, 267.0], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t_vals, range_vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "t": {"values": t_vals}, + "composite": COMPOSITE_TWO_POINTS_XYZ, + } + assert cov["ranges"]["2t"]["values"] == range_vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **REFORECAST_METADATA_BASE}