diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index 5ab757b..d834af8 100644 --- a/covjsonkit/encoder/BoundingBox.py +++ b/covjsonkit/encoder/BoundingBox.py @@ -117,7 +117,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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 @@ -203,6 +202,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Circle.py b/covjsonkit/encoder/Circle.py index 3dc6db5..2271480 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -114,7 +114,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjsonå - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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 @@ -200,6 +200,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Frame.py b/covjsonkit/encoder/Frame.py index 6cb7755..7f70a83 100644 --- a/covjsonkit/encoder/Frame.py +++ b/covjsonkit/encoder/Frame.py @@ -114,7 +114,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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 @@ -195,6 +195,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Grid.py b/covjsonkit/encoder/Grid.py index ecdd12e..b9a2fcb 100644 --- a/covjsonkit/encoder/Grid.py +++ b/covjsonkit/encoder/Grid.py @@ -120,7 +120,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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 @@ -218,6 +217,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Path.py b/covjsonkit/encoder/Path.py index 049e4ae..0133aa4 100644 --- a/covjsonkit/encoder/Path.py +++ b/covjsonkit/encoder/Path.py @@ -114,7 +114,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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] @@ -225,6 +225,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Position.py b/covjsonkit/encoder/Position.py index b659454..790d627 100644 --- a/covjsonkit/encoder/Position.py +++ b/covjsonkit/encoder/Position.py @@ -123,7 +123,7 @@ def from_xarray(self, datasets): return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): """ Converts a Polytope result into an OGC CoverageJSON coverageCollection of type PointSeries Args: @@ -144,7 +144,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 @@ -251,6 +251,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Shapefile.py b/covjsonkit/encoder/Shapefile.py index 0741219..19abab8 100644 --- a/covjsonkit/encoder/Shapefile.py +++ b/covjsonkit/encoder/Shapefile.py @@ -114,7 +114,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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 @@ -195,6 +195,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 32eb98a..9a13924 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -123,7 +123,7 @@ def from_xarray(self, datasets): return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): """ Converts a Polytope result into an OGC CoverageJSON coverageCollection of type PointSeries Args: @@ -144,7 +144,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 @@ -471,3 +471,11 @@ def from_polytope_step(self, result): logging.debug("Coverage creation: %s", delta) # noqa: E501 return self.covjson + + def from_polytope_reforecast(self, result): + """Encode reforecast data that uses "hdate" as the time axis. + + Each hdate produces a separate coverage (one per point × hdate). + Steps within a single hdate become that coverage's t-axis values. + """ + return self.from_polytope(result, date_key="hdate") diff --git a/covjsonkit/encoder/VerticalProfile.py b/covjsonkit/encoder/VerticalProfile.py index 6f298a2..7648df6 100644 --- a/covjsonkit/encoder/VerticalProfile.py +++ b/covjsonkit/encoder/VerticalProfile.py @@ -121,7 +121,7 @@ def from_xarray(self, datasets): return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} range_dict = {} @@ -135,7 +135,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 @@ -239,6 +239,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_month(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/Wkt.py b/covjsonkit/encoder/Wkt.py index 75526c4..f5b4e18 100644 --- a/covjsonkit/encoder/Wkt.py +++ b/covjsonkit/encoder/Wkt.py @@ -118,7 +118,7 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjson - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): coords = {} mars_metadata = {} @@ -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 @@ -204,6 +204,9 @@ def from_polytope(self, result): return self.covjson + def from_polytope_reforecast(self, result): + return self.from_polytope(result, date_key="hdate") + def from_polytope_step(self, result): coords = {} mars_metadata = {} diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index b4bcf84..9be7000 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -1,4 +1,8 @@ +from __future__ import annotations + +import typing from abc import ABC, abstractmethod +from typing import Any import orjson import pandas as pd @@ -7,6 +11,9 @@ from covjsonkit.param_db import get_param_ids, get_params, get_units +if typing.TYPE_CHECKING: + from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + class Encoder(ABC): def __init__(self, type, domaintype): @@ -149,13 +156,31 @@ 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: TensorIndexTree, + 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 reanalysis/hindcast 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: + # TODO: Add assert len(child.values) == 1 here mars_metadata[child.axis.name] = child.values[0] def handle_specific_axes(child): @@ -165,8 +190,11 @@ 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] + # TODO: Discuss before merging — for reforecasts the hdate is + # the forecast initialisation time so using it as "Forecast date" + # makes sense, but this may need revisiting for reanalysis. mars_metadata["Forecast date"] = str(child.values[0]) for date in dates: coords[date] = {} @@ -202,7 +230,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 +239,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 +557,8 @@ def from_xarray(self, dataset): pass @abstractmethod - def from_polytope(self, result): + def from_polytope(self, result, date_key="date"): pass + + def from_polytope_reforecast(self, result): + raise NotImplementedError(f"{type(self).__name__} does not implement from_polytope_reforecast") diff --git a/pyproject.toml b/pyproject.toml index c8d7aff..2266d4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,3 +23,7 @@ geo = [ "rasterio", "shapely", ] +tests = [ + "pytest", + "polytope-python", +] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c5bfbc9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import numpy as np +from polytope_feature.datacube.datacube_axis import IntDatacubeAxis +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + + +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.""" + 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 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..02657c1 --- /dev/null +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -0,0 +1,253 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +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, +} + + +class TestBoundingBoxFromPolytope: + def test_single_date_single_step_two_points(self): + 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", ("fc",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + + 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 = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + + 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 = 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"), [[264.9], [265.1]]), + (np.datetime64("2025-07-15T06:00:00"), [[266.0], [267.0]]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + t = tip(branch) + t.add_child(make_point(48.0, 11.0, vals[0])) + t.add_child(make_point(50.0, 12.0, vals[1])) + date_node.add_child(branch) + + 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 = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0, 6)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9, 270.1])) + fc.add_child(make_point(50.0, 12.0, [265.1, 271.3])) + + 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..033b171 --- /dev/null +++ b/tests/test_encoder_circle_from_polytope.py @@ -0,0 +1,165 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +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], + ], +} + +EXPECTED_REFORECAST_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, +} + + +class TestCircleFromPolytope: + def test_single_date_single_step_three_points(self): + 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", ("fc",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(49.0, 11.5, [265.5])) + fc.add_child(make_point(50.0, 12.0, [266.1])) + + 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 = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(49.0, 11.5, [265.5])) + fc.add_child(make_point(50.0, 12.0, [266.1])) + + 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", **EXPECTED_REFORECAST_METADATA} + + def test_reforecast_two_hdates_three_points(self): + 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"), [[264.9], [265.5], [266.1]]), + (np.datetime64("2025-07-15T06:00:00"), [[270.0], [271.0], [272.0]]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + t = tip(branch) + t.add_child(make_point(48.0, 11.0, vals[0])) + t.add_child(make_point(49.0, 11.5, vals[1])) + t.add_child(make_point(50.0, 12.0, vals[2])) + date_node.add_child(branch) + + 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, **EXPECTED_REFORECAST_METADATA} diff --git a/tests/test_encoder_frame_from_polytope.py b/tests/test_encoder_frame_from_polytope.py new file mode 100644 index 0000000..1e97bf0 --- /dev/null +++ b/tests/test_encoder_frame_from_polytope.py @@ -0,0 +1,164 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +COMPOSITE_TWO_POINTS = { + "dataType": "tuple", + "coordinates": ["x", "y", "z"], + "values": [[48.0, 11.0, 0], [50.0, 12.0, 0]], +} + +REFORECAST_SHARED_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, +} + + +class TestFrameFromPolytope: + def test_single_date_single_step_two_points(self): + 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", ("fc",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(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, + } + + 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): + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + + 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, + } + + assert cov["ranges"] == { + "2t": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["2t"], + "values": [264.9, 265.1], + } + } + + assert cov["mars:metadata"] == { + **REFORECAST_SHARED_METADATA, + "Forecast date": "2025-07-14T06:00:00Z", + } + + def test_reforecast_two_hdates_two_points(self): + 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"), [[264.9], [265.1]]), + (np.datetime64("2025-07-15T06:00:00"), [[266.0], [267.0]]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + t = tip(branch) + t.add_child(make_point(48.0, 11.0, vals[0])) + t.add_child(make_point(50.0, 12.0, vals[1])) + date_node.add_child(branch) + + 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, + } + assert cov["ranges"]["2t"]["values"] == vals + assert cov["mars:metadata"] == { + **REFORECAST_SHARED_METADATA, + "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..29e5d00 --- /dev/null +++ b/tests/test_encoder_grid_from_polytope.py @@ -0,0 +1,223 @@ +import numpy as np +from conftest import chain, make_point, node, 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], + } +} + +EXPECTED_REFORECAST_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, +} + + +class TestGridFromPolytope: + """Tests for Grid encoder's from_polytope method.""" + + def test_2x2_grid(self): + """2×2 grid: 2 latitudes, 2 longitudes, param 167 (2t), step 0.""" + grid_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]), + ] + 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_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 _build_reforecast_branch(self, hdate_val, grid_points): + """Build a single hdate branch with grid points.""" + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(branch) + for lat, lon, vals in grid_points: + fc.add_child(make_point(lat, lon, vals)) + return branch + + def test_reforecast_single_hdate_2x2_grid(self): + """Single hdate with 2×2 grid → 1 Grid coverage.""" + grid_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]), + ] + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + self._build_reforecast_branch(np.datetime64("2025-07-14T06:00:00"), grid_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"] == { + **EXPECTED_REFORECAST_METADATA, + "Forecast date": "2025-07-14T06:00:00Z", + } + + def test_reforecast_two_hdates_2x2_grid(self): + """Two hdates each with 2×2 grid → 2 Grid coverages.""" + grid_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]), + ] + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + ) + date_node = tip(tree) + for hdate_val in [np.datetime64("2025-07-14T06:00:00"), np.datetime64("2025-07-15T06:00:00")]: + date_node.add_child(self._build_reforecast_branch(hdate_val, grid_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"] == { + **EXPECTED_REFORECAST_METADATA, + "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..7de459a --- /dev/null +++ b/tests/test_encoder_path_from_polytope.py @@ -0,0 +1,203 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + + +class TestPathFromPolytope: + """Tests for Path (Trajectory) encoder's from_polytope method.""" + + def _build_path_tree(self, points, param="167", step=0): + """Build a path tree with given points. + + points: list of (lat, lon, result_value) tuples + Each point is a separate lat→lon subtree. + The step value(s) become the 't' in the composite tuple. + """ + step_tuple = step if isinstance(step, tuple) else (step,) + + 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", (param,)), + node("step", step_tuple), + node("stream", ("oper",)), + node("type", ("fc",)), + ) + parent = tip(tree) + for lat, lon, vals in points: + if not isinstance(vals, list): + vals = [vals] + parent.add_child(make_point(lat, lon, vals)) + + return tree + + 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 = self._build_path_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.""" + + SHARED_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, + } + + 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 = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + 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"] == { + **self.SHARED_METADATA, + "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 = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + ) + date_node = tip(tree) + + for hdate_val in [np.datetime64("2025-07-14T06:00:00"), np.datetime64("2025-07-15T06:00:00")]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(branch) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + date_node.add_child(branch) + + 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"] == {**self.SHARED_METADATA, "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..90ab4a1 --- /dev/null +++ b/tests/test_encoder_position_from_polytope.py @@ -0,0 +1,251 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + + +class TestPositionFromPolytope: + """Tests for Position (PointSeries) encoder's from_polytope method.""" + + def _build_position_tree(self, points, param="167", steps=(0, 6)): + """Build a Position tree. + + points: list of (lat, lon, result_list) tuples. + result_list has one value per step. + """ + 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", (param,)), + node("step", steps), + 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 test_single_point_two_steps(self): + """1 point, 2 steps → 1 coverage with t=[step0, step6].""" + points = [(48.0, 11.0, [264.9, 263.8])] + tree = self._build_position_tree(points) + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "PointSeries" + + # Referencing (folded from former test_referencing) + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["latitude", "longitude", "levelist"] + assert ref["system"]["type"] == "GeographicCRS" + + # Parameters (folded from former test_parameters_block) + 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).""" + points = [ + (48.0, 11.0, [264.9, 263.8]), + (50.0, 13.0, [265.1, 264.2]), + ] + tree = self._build_position_tree(points) + 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.""" + points = [(48.0, 11.0, [264.9])] + tree = self._build_position_tree(points, steps=(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).""" + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + + 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 × 2 points → 4 coverages.""" + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + ) + date_node = tip(tree) + + for hdate_val, point_vals in [ + (np.datetime64("2025-07-14T06:00:00"), [[264.9], [265.1]]), + (np.datetime64("2025-07-15T06:00:00"), [[266.0], [267.0]]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(branch) + fc.add_child(make_point(48.0, 11.0, point_vals[0])) + fc.add_child(make_point(50.0, 12.0, point_vals[1])) + date_node.add_child(branch) + + 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..51183d1 --- /dev/null +++ b/tests/test_encoder_shapefile_from_polytope.py @@ -0,0 +1,164 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +EXPECTED_REFORECAST_METADATA = { + "class": "ce", + "date": np.datetime64("2024-03-01"), + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, +} + + +class TestShapefileFromPolytope: + def test_single_date_single_step_two_points(self): + 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", ("fc",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(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": { + "dataType": "tuple", + "coordinates": ["x", "y", "z"], + "values": [[48.0, 11.0, 0], [50.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, + } + + +class TestShapefileFromPolytopeReforecast: + def test_reforecast_single_hdate_two_points(self): + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + + 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": { + "dataType": "tuple", + "coordinates": ["x", "y", "z"], + "values": [[48.0, 11.0, 0], [50.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"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_REFORECAST_METADATA} + + def test_reforecast_two_hdates_two_points(self): + 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"), [[264.9], [265.1]]), + (np.datetime64("2025-07-15T06:00:00"), [[266.0], [267.0]]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + t = tip(branch) + t.add_child(make_point(48.0, 11.0, vals[0])) + t.add_child(make_point(50.0, 12.0, vals[1])) + date_node.add_child(branch) + + 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": { + "dataType": "tuple", + "coordinates": ["x", "y", "z"], + "values": [[48.0, 11.0, 0], [50.0, 12.0, 0]], + }, + } + assert cov["ranges"]["2t"]["values"] == range_vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_REFORECAST_METADATA} 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..78fb76e --- /dev/null +++ b/tests/test_encoder_wkt_from_polytope.py @@ -0,0 +1,161 @@ +import numpy as np +from conftest import chain, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +COMPOSITE_TWO_POINTS = { + "dataType": "tuple", + "coordinates": ["x", "y", "z"], + "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", + "step": 0, + "stream": "efcl", + "type": "sfo", + "number": 0, +} + + +class TestWktFromPolytope: + def test_single_date_single_step_two_points(self): + 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", ("fc",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(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, + } + + 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): + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc = tip(tree) + fc.add_child(make_point(48.0, 11.0, [264.9])) + fc.add_child(make_point(50.0, 12.0, [265.1])) + + 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, + } + + 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", + **EXPECTED_REFORECAST_METADATA, + } + + def test_reforecast_two_hdates_two_points(self): + 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"), [[264.9], [265.1]]), + (np.datetime64("2025-07-15T06:00:00"), [[266.0], [267.0]]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + t = tip(branch) + t.add_child(make_point(48.0, 11.0, vals[0])) + t.add_child(make_point(50.0, 12.0, vals[1])) + date_node.add_child(branch) + + 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, + } + assert cov["ranges"]["2t"]["values"] == range_vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_REFORECAST_METADATA}