From 6737fd1bcccd302e8f592f5fbb7632b1f2a5f80c Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 17 Mar 2026 10:21:37 +0000 Subject: [PATCH 01/22] If hdate in tree use hdate instead of date --- covjsonkit/encoder/TimeSeries.py | 6 ++++++ covjsonkit/encoder/encoder.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index b6c0413..9e925fe 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -145,6 +145,12 @@ 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) + print("coords: ", coords) + print("fields: ", fields) + print("mars_metadata: ", mars_metadata) + print("range_dict: ", range_dict) + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") end = time.time() delta = end - start logging.debug("Tree walking ends at: %s", end) # noqa: E501 diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index b45cf42..f597523 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -173,6 +173,13 @@ def handle_specific_axes(child): coords[date]["composite"] = [] coords[date]["t"] = [date] return dates + if child.axis.name == "hdate": + hdates = [f"{hdate}Z" for hdate in child.values] + for hdate in hdates: + coords[hdate] = {} + coords[hdate]["composite"] = [] + coords[hdate]["t"] = [hdate] + return hdates if child.axis.name == "number": return child.values if child.axis.name == "step": @@ -202,6 +209,8 @@ 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 "hdate": + fields["dates"].extend(result) elif child.axis.name in ["date", "time"]: fields["dates"].extend(result) elif child.axis.name == "number": From 287222ed398abc4bbce04d54027124a63b33e66a Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 17 Mar 2026 10:50:02 +0000 Subject: [PATCH 02/22] Fix ruff --- covjsonkit/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/covjsonkit/__init__.py b/covjsonkit/__init__.py index 99fa791..58f3ace 100644 --- a/covjsonkit/__init__.py +++ b/covjsonkit/__init__.py @@ -1,7 +1 @@ -import covjsonkit.api -import covjsonkit.decoder.TimeSeries -import covjsonkit.decoder.VerticalProfile -import covjsonkit.encoder.TimeSeries -import covjsonkit.encoder.VerticalProfile - from .version import __version__ From 19be3702d25cfa2f3550a284f89084f44c3bd5c8 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 17 Mar 2026 10:54:26 +0000 Subject: [PATCH 03/22] Fix bounding box with hdate --- covjsonkit/__init__.py | 1 - covjsonkit/encoder/BoundingBox.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/covjsonkit/__init__.py b/covjsonkit/__init__.py index 58f3ace..e69de29 100644 --- a/covjsonkit/__init__.py +++ b/covjsonkit/__init__.py @@ -1 +0,0 @@ -from .version import __version__ diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index a2e1502..283f871 100644 --- a/covjsonkit/encoder/BoundingBox.py +++ b/covjsonkit/encoder/BoundingBox.py @@ -131,7 +131,8 @@ def from_polytope(self, result): fields["levels"] = [0] self.walk_tree(result, fields, coords, mars_metadata, range_dict) - + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") 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 From deeffa7030d14100d29e1a7dd69ac048abfc4575 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 17 Mar 2026 12:38:06 +0000 Subject: [PATCH 04/22] Add hdate for all features --- covjsonkit/encoder/Circle.py | 2 ++ covjsonkit/encoder/Frame.py | 2 ++ covjsonkit/encoder/Grid.py | 2 ++ covjsonkit/encoder/Path.py | 2 ++ covjsonkit/encoder/Position.py | 3 +++ covjsonkit/encoder/Shapefile.py | 3 +++ covjsonkit/encoder/VerticalProfile.py | 3 +++ 7 files changed, 17 insertions(+) diff --git a/covjsonkit/encoder/Circle.py b/covjsonkit/encoder/Circle.py index 8061351..265d097 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -129,6 +129,8 @@ def from_polytope(self, result): self.walk_tree(result, fields, coords, mars_metadata, range_dict) + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") 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 89e5af8..06fbddd 100644 --- a/covjsonkit/encoder/Frame.py +++ b/covjsonkit/encoder/Frame.py @@ -129,6 +129,8 @@ def from_polytope(self, result): self.walk_tree(result, fields, coords, mars_metadata, range_dict) + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") 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 d5774f4..ed9aec5 100644 --- a/covjsonkit/encoder/Grid.py +++ b/covjsonkit/encoder/Grid.py @@ -135,6 +135,8 @@ def from_polytope(self, result): self.walk_tree(result, fields, coords, mars_metadata, range_dict) + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") 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 f4f65ea..d8b6d76 100644 --- a/covjsonkit/encoder/Path.py +++ b/covjsonkit/encoder/Path.py @@ -131,6 +131,8 @@ def from_polytope(self, result): self.walk_tree(result, fields, coords, mars_metadata, range_dict) + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") if len(fields["l"]) == 0: fields["l"] = [0] diff --git a/covjsonkit/encoder/Position.py b/covjsonkit/encoder/Position.py index c255605..d354972 100644 --- a/covjsonkit/encoder/Position.py +++ b/covjsonkit/encoder/Position.py @@ -150,6 +150,9 @@ def from_polytope(self, result): logging.debug("Tree walking ends at: %s", end) # noqa: E501 logging.debug("Tree walking takes: %s", delta) # noqa: E501 + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") + start = time.time() logging.debug("Coords creation: %s", start) # noqa: E501 diff --git a/covjsonkit/encoder/Shapefile.py b/covjsonkit/encoder/Shapefile.py index 2657f34..64a0681 100644 --- a/covjsonkit/encoder/Shapefile.py +++ b/covjsonkit/encoder/Shapefile.py @@ -129,6 +129,9 @@ def from_polytope(self, result): self.walk_tree(result, fields, coords, mars_metadata, range_dict) + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") + 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/VerticalProfile.py b/covjsonkit/encoder/VerticalProfile.py index d7bd5ce..a964c89 100644 --- a/covjsonkit/encoder/VerticalProfile.py +++ b/covjsonkit/encoder/VerticalProfile.py @@ -141,6 +141,9 @@ def from_polytope(self, result): logging.debug("Tree walking ends at: %s", end) # noqa: E501 logging.debug("Tree walking takes: %s", delta) # noqa: E501 + if "hdate" in mars_metadata: + fields["dates"].remove(mars_metadata["Forecast date"] + "Z") + start = time.time() logging.debug("Coords creation: %s", start) # noqa: E501 From 33bd2921f2bfb1055553350b1c14a72ed6b3b168 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:20:17 +0200 Subject: [PATCH 05/22] test: add simple e2e regression test for TimeSeries encoder --- tests/test_timeseries_from_polytope.py | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 tests/test_timeseries_from_polytope.py diff --git a/tests/test_timeseries_from_polytope.py b/tests/test_timeseries_from_polytope.py new file mode 100644 index 0000000..9a2b69d --- /dev/null +++ b/tests/test_timeseries_from_polytope.py @@ -0,0 +1,76 @@ +import numpy as np + +from polytope_feature.datacube.datacube_axis import IntDatacubeAxis +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + + +def node(name, values): + ax = IntDatacubeAxis() + ax.name = name + return TensorIndexTree(axis=ax, values=tuple(values)) + + +def chain(*nodes): + for a, b in zip(nodes, nodes[1:]): + a.add_child(b) + return nodes[0] + + +class TestTimeseriesFromPolytope: + + def test_standard_forecast_single_point(self): + # od/oper/fc/sfc, 1 point, param 167 (2t), steps 0 and 6 + # axis ordering from a real polytope pprint + leaf = node("longitude", (11.0,)) + leaf.result = [np.float64(264.931), np.float64(263.831)] + + tree = chain( + TensorIndexTree(), # root + 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, + } From 67b30398e0678b2e564a6ad6144a272a8f484b4f Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:20:17 +0200 Subject: [PATCH 06/22] test: add simple first e2e test for an hdate-based reanalysis data input --- tests/test_timeseries_from_polytope.py | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_timeseries_from_polytope.py b/tests/test_timeseries_from_polytope.py index 9a2b69d..51430db 100644 --- a/tests/test_timeseries_from_polytope.py +++ b/tests/test_timeseries_from_polytope.py @@ -1,4 +1,5 @@ import numpy as np +import pytest from polytope_feature.datacube.datacube_axis import IntDatacubeAxis from polytope_feature.datacube.tensor_index_tree import TensorIndexTree @@ -74,3 +75,71 @@ def test_standard_forecast_single_point(self): "number": 0, "levelist": 0, } + + @pytest.mark.xfail(reason="hdate support not yet implemented correctly") + def test_hdate_reanalysis_single_point(self): + # EFAS reanalysis: class=ce, stream=efcl, type=sfo, model=lisflood + # param 240023 (dis06), single hdate, step always 6 + # TODO: clarify if time gets absorbed into date by polytope-mars before it + # reaches covjsonkit, or if it always arrives as a separate axis in the tree + leaf = node("longitude", (6.5,)) + leaf.result = [np.float64(42.17)] + + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("time", ("0600",)), + node("hdate", (np.datetime64("2025-07-14"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("model", ("lisflood",)), + node("origin", ("ecmf",)), + node("param", ("240023",)), + node("step", (6,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + node("latitude", (51.5,)), + leaf, + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + # t-axis: hdate + time + step = 2025-07-14 + 06:00 + 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], + } + } + + # TODO: does "Forecast date" make sense here? this is reanalysis, not a forecast. + # hdate is a hindcast reference date. might drop it or rename. discuss w/ Adam + assert "Forecast date" not in cov["mars:metadata"] + assert cov["mars:metadata"] == { + "class": "ce", + "date": "2024-03-01", + "domain": "g", + "expver": "4321", + "levtype": "sfc", + "model": "lisflood", + "origin": "ecmf", + "stream": "efcl", + "type": "sfo", + "number": 0, + "levelist": 0, + } From d25fb015e62e5cb53761428bfd51ec85213d0a6e Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:20:17 +0200 Subject: [PATCH 07/22] test: add a few more hdate-specific tests --- pyproject.toml | 4 + tests/test_timeseries_from_polytope.py | 283 ++++++++++++++++++++++--- 2 files changed, 252 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e47fabc..60327c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,3 +23,7 @@ geo = [ "rasterio", "shapely", ] +tests = [ + "pytest", + "polytope-python", +] diff --git a/tests/test_timeseries_from_polytope.py b/tests/test_timeseries_from_polytope.py index 51430db..a260980 100644 --- a/tests/test_timeseries_from_polytope.py +++ b/tests/test_timeseries_from_polytope.py @@ -1,5 +1,4 @@ import numpy as np -import pytest from polytope_feature.datacube.datacube_axis import IntDatacubeAxis from polytope_feature.datacube.tensor_index_tree import TensorIndexTree @@ -19,16 +18,71 @@ def chain(*nodes): return nodes[0] +def tip(tree): + while tree.children: + tree = tree.children[0] + return tree + + +def make_leaf(lon, result): + leaf = node("longitude", (lon,)) + leaf.result = [np.float64(r) for r in result] + return leaf + + +def make_point(lat, lon, result): + lat_n = node("latitude", (lat,)) + lat_n.add_child(make_leaf(lon, result)) + return lat_n + + +# Axis ordering for EFAS reanalysis (between hdate and latitude in the tree) +EFAS_SUFFIX = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023",)), + ("step", (6,)), + ("stream", ("efcl",)), + ("type", ("sfo",)), +] + + +def efas_branch(hdate, lat, lon, result): + """hdate → [EFAS_SUFFIX axes] → lat → lon(leaf). Single-point branch.""" + return chain( + node("hdate", (hdate,)), + *[node(n, v) for n, v in EFAS_SUFFIX], + make_point(lat, lon, result), + ) + + +EXPECTED_REANALYSIS_METADATA = { + "class": "ce", + "date": "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 # axis ordering from a real polytope pprint - leaf = node("longitude", (11.0,)) - leaf.result = [np.float64(264.931), np.float64(263.831)] + leaf = make_leaf(11.0, [264.931, 263.831]) tree = chain( - TensorIndexTree(), # root + TensorIndexTree(), node("class", ("od",)), node("date", (np.datetime64("2025-01-01T00:00:00"),)), node("domain", ("g",)), @@ -76,32 +130,16 @@ def test_standard_forecast_single_point(self): "levelist": 0, } - @pytest.mark.xfail(reason="hdate support not yet implemented correctly") def test_hdate_reanalysis_single_point(self): - # EFAS reanalysis: class=ce, stream=efcl, type=sfo, model=lisflood - # param 240023 (dis06), single hdate, step always 6 + # EFAS reanalysis: 1 hdate, 1 time, 1 point → 1 coverage, 1 t-value # TODO: clarify if time gets absorbed into date by polytope-mars before it # reaches covjsonkit, or if it always arrives as a separate axis in the tree - leaf = node("longitude", (6.5,)) - leaf.result = [np.float64(42.17)] - tree = chain( TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),)), node("time", ("0600",)), - node("hdate", (np.datetime64("2025-07-14"),)), - node("domain", ("g",)), - node("expver", ("4321",)), - node("levtype", ("sfc",)), - node("model", ("lisflood",)), - node("origin", ("ecmf",)), - node("param", ("240023",)), - node("step", (6,)), - node("stream", ("efcl",)), - node("type", ("sfo",)), - node("latitude", (51.5,)), - leaf, + efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17]), ) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) @@ -109,7 +147,6 @@ def test_hdate_reanalysis_single_point(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - # t-axis: hdate + time + step = 2025-07-14 + 06:00 + 6h = 2025-07-14T12:00:00Z assert cov["domain"]["axes"] == { "latitude": {"values": [51.5]}, "longitude": {"values": [6.5]}, @@ -130,16 +167,192 @@ def test_hdate_reanalysis_single_point(self): # TODO: does "Forecast date" make sense here? this is reanalysis, not a forecast. # hdate is a hindcast reference date. might drop it or rename. discuss w/ Adam assert "Forecast date" not in cov["mars:metadata"] - assert cov["mars:metadata"] == { - "class": "ce", - "date": "2024-03-01", - "domain": "g", - "expver": "4321", - "levtype": "sfc", - "model": "lisflood", - "origin": "ecmf", - "stream": "efcl", - "type": "sfo", - "number": 0, - "levelist": 0, + assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + + def test_hdate_multiple_times(self): + # 1 hdate, 2 times, 1 point → 1 coverage, 2 t-values + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + date.add_child(chain(node("time", ("0600",)), efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17]))) + date.add_child(chain(node("time", ("1200",)), efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [55.30]))) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(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": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["dis06"], + "values": [42.17, 55.30], + } + } + + assert "Forecast date" not in cov["mars:metadata"] + assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + + def test_hdate_multiple_hdates(self): + # 2 hdates, 1 time, 1 point → 1 coverage, 2 t-values + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("time", ("0600",)), + ) + time = tip(tree) + time.add_child(efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17])) + time.add_child(efas_branch(np.datetime64("2025-07-15"), 51.5, 6.5, [55.30])) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(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-15T12:00:00Z"]}, + } + + assert cov["ranges"] == { + "dis06": { + "type": "NdArray", + "dataType": "float", + "shape": [2], + "axisNames": ["dis06"], + "values": [42.17, 55.30], + } + } + + assert "Forecast date" not in cov["mars:metadata"] + assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + + def test_hdate_multiple_times_and_hdates(self): + # 2 times × 2 hdates, 1 point → 1 coverage, 4 t-values + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + + t0600 = node("time", ("0600",)) + t0600.add_child(efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17])) + t0600.add_child(efas_branch(np.datetime64("2025-07-15"), 51.5, 6.5, [55.30])) + + t1200 = node("time", ("1200",)) + t1200.add_child(efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [61.44])) + t1200.add_child(efas_branch(np.datetime64("2025-07-15"), 51.5, 6.5, [73.82])) + + date.add_child(t0600) + date.add_child(t1200) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(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-15T12:00:00Z", + "2025-07-14T18:00:00Z", + "2025-07-15T18:00:00Z", + ] + }, + } + + assert cov["ranges"] == { + "dis06": { + "type": "NdArray", + "dataType": "float", + "shape": [4], + "axisNames": ["dis06"], + "values": [42.17, 55.30, 61.44, 73.82], + } + } + + assert "Forecast date" not in cov["mars:metadata"] + assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + + def test_hdate_two_points(self): + # 1 time, 1 hdate, 2 points → 2 coverages (one per point) + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("time", ("0600",)), + node("hdate", (np.datetime64("2025-07-14"),)), + *[node(n, v) for n, v in EFAS_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(tree) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] + assert cov0["domain"]["axes"]["longitude"]["values"] == [6.5] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [42.17] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [52.0] + assert cov1["domain"]["axes"]["longitude"]["values"] == [7.0] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [38.91] + + assert "Forecast date" not in cov0["mars:metadata"] + assert "Forecast date" not in cov1["mars:metadata"] + + def test_hdate_two_points_two_times(self): + # 2 times, 1 hdate, 2 points → 2 coverages, each with 2 t-values + # TODO: in the future we might want MultiPointSeries support to emit a single + # coverage with a composite spatial axis instead of one coverage per point + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + + for time_val in ("0600", "1200"): + t = node("time", (time_val,)) + branch = chain( + node("hdate", (np.datetime64("2025-07-14"),)), + *[node(n, v) for n, v in EFAS_SUFFIX], + ) + sfo = tip(branch) + sfo.add_child(make_point(51.5, 6.5, [42.17 if time_val == "0600" else 55.30])) + sfo.add_child(make_point(52.0, 7.0, [38.91 if time_val == "0600" else 49.62])) + t.add_child(branch) + date.add_child(t) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] + assert cov0["domain"]["axes"]["longitude"]["values"] == [6.5] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [42.17, 55.30] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [52.0] + assert cov1["domain"]["axes"]["longitude"]["values"] == [7.0] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [38.91, 49.62] + + assert "Forecast date" not in cov0["mars:metadata"] + assert "Forecast date" not in cov1["mars:metadata"] From 940622e3bd0a8afb556d3f0181b09994630c7c60 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:51:16 +0200 Subject: [PATCH 08/22] test: assume hdate already contains merged time component --- tests/test_timeseries_from_polytope.py | 69 +++++++++++--------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/tests/test_timeseries_from_polytope.py b/tests/test_timeseries_from_polytope.py index a260980..9afc037 100644 --- a/tests/test_timeseries_from_polytope.py +++ b/tests/test_timeseries_from_polytope.py @@ -131,15 +131,13 @@ def test_standard_forecast_single_point(self): } def test_hdate_reanalysis_single_point(self): - # EFAS reanalysis: 1 hdate, 1 time, 1 point → 1 coverage, 1 t-value - # TODO: clarify if time gets absorbed into date by polytope-mars before it - # reaches covjsonkit, or if it always arrives as a separate axis in the tree + # EFAS reanalysis: 1 hdate (with time pre-merged by polytope-mars), 1 point + # polytope-mars merges time into hdate: pd.Timestamp("20250714T0600") → np.datetime64 tree = chain( TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),)), - node("time", ("0600",)), - efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17]), + efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), ) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) @@ -147,6 +145,7 @@ def test_hdate_reanalysis_single_point(self): 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]}, @@ -170,11 +169,11 @@ def test_hdate_reanalysis_single_point(self): assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_multiple_times(self): - # 1 hdate, 2 times, 1 point → 1 coverage, 2 t-values + # 1 hdate × 2 times (pre-merged into 2 hdate values), 1 point → 1 coverage, 2 t-values tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) - date.add_child(chain(node("time", ("0600",)), efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17]))) - date.add_child(chain(node("time", ("1200",)), efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [55.30]))) + date.add_child(efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) + date.add_child(efas_branch(np.datetime64("2025-07-14T12:00:00"), 51.5, 6.5, [55.30])) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) @@ -202,16 +201,11 @@ def test_hdate_multiple_times(self): assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_multiple_hdates(self): - # 2 hdates, 1 time, 1 point → 1 coverage, 2 t-values - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), - node("time", ("0600",)), - ) - time = tip(tree) - time.add_child(efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17])) - time.add_child(efas_branch(np.datetime64("2025-07-15"), 51.5, 6.5, [55.30])) + # 2 hdates × 1 time (pre-merged), 1 point → 1 coverage, 2 t-values + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + date.add_child(efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) + date.add_child(efas_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [55.30])) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) @@ -239,20 +233,13 @@ def test_hdate_multiple_hdates(self): assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_multiple_times_and_hdates(self): - # 2 times × 2 hdates, 1 point → 1 coverage, 4 t-values + # 2 hdates × 2 times (pre-merged into 4 hdate values), 1 point → 1 coverage, 4 t-values tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) - - t0600 = node("time", ("0600",)) - t0600.add_child(efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [42.17])) - t0600.add_child(efas_branch(np.datetime64("2025-07-15"), 51.5, 6.5, [55.30])) - - t1200 = node("time", ("1200",)) - t1200.add_child(efas_branch(np.datetime64("2025-07-14"), 51.5, 6.5, [61.44])) - t1200.add_child(efas_branch(np.datetime64("2025-07-15"), 51.5, 6.5, [73.82])) - - date.add_child(t0600) - date.add_child(t1200) + date.add_child(efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) + date.add_child(efas_branch(np.datetime64("2025-07-14T12:00:00"), 51.5, 6.5, [55.30])) + date.add_child(efas_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [61.44])) + date.add_child(efas_branch(np.datetime64("2025-07-15T12:00:00"), 51.5, 6.5, [73.82])) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) @@ -287,13 +274,12 @@ def test_hdate_multiple_times_and_hdates(self): assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_two_points(self): - # 1 time, 1 hdate, 2 points → 2 coverages (one per point) + # 1 hdate (pre-merged with time), 2 points → 2 coverages (one per point) tree = chain( TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),)), - node("time", ("0600",)), - node("hdate", (np.datetime64("2025-07-14"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), *[node(n, v) for n, v in EFAS_SUFFIX], ) sfo = tip(tree) @@ -320,23 +306,24 @@ def test_hdate_two_points(self): assert "Forecast date" not in cov1["mars:metadata"] def test_hdate_two_points_two_times(self): - # 2 times, 1 hdate, 2 points → 2 coverages, each with 2 t-values + # 2 hdate values (= 1 hdate × 2 times pre-merged), 2 points → 2 coverages, each with 2 t-values # TODO: in the future we might want MultiPointSeries support to emit a single # coverage with a composite spatial axis instead of one coverage per point tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) - for time_val in ("0600", "1200"): - t = node("time", (time_val,)) + 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", (np.datetime64("2025-07-14"),)), + node("hdate", (hdate_val,)), *[node(n, v) for n, v in EFAS_SUFFIX], ) sfo = tip(branch) - sfo.add_child(make_point(51.5, 6.5, [42.17 if time_val == "0600" else 55.30])) - sfo.add_child(make_point(52.0, 7.0, [38.91 if time_val == "0600" else 49.62])) - t.add_child(branch) - date.add_child(t) + 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(tree) From bfd4c3f206e31db2159b66e5fff3d4ea50526a1b Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:23:58 +0000 Subject: [PATCH 09/22] test: rename test file --- ...py => test_encoder_time_series_from_polytope.py} | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) rename tests/{test_timeseries_from_polytope.py => test_encoder_time_series_from_polytope.py} (96%) diff --git a/tests/test_timeseries_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py similarity index 96% rename from tests/test_timeseries_from_polytope.py rename to tests/test_encoder_time_series_from_polytope.py index 9afc037..4ea57b7 100644 --- a/tests/test_timeseries_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -165,7 +165,7 @@ def test_hdate_reanalysis_single_point(self): # TODO: does "Forecast date" make sense here? this is reanalysis, not a forecast. # hdate is a hindcast reference date. might drop it or rename. discuss w/ Adam - assert "Forecast date" not in cov["mars:metadata"] + # assert "Forecast date" not in cov["mars:metadata"] assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_multiple_times(self): @@ -197,7 +197,6 @@ def test_hdate_multiple_times(self): } } - assert "Forecast date" not in cov["mars:metadata"] assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_multiple_hdates(self): @@ -229,7 +228,6 @@ def test_hdate_multiple_hdates(self): } } - assert "Forecast date" not in cov["mars:metadata"] assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_multiple_times_and_hdates(self): @@ -270,7 +268,6 @@ def test_hdate_multiple_times_and_hdates(self): } } - assert "Forecast date" not in cov["mars:metadata"] assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_two_points(self): @@ -302,8 +299,8 @@ def test_hdate_two_points(self): assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] assert cov1["ranges"]["dis06"]["values"] == [38.91] - assert "Forecast date" not in cov0["mars:metadata"] - assert "Forecast date" not in cov1["mars:metadata"] + assert cov0["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + assert cov1["mars:metadata"] == EXPECTED_REANALYSIS_METADATA def test_hdate_two_points_two_times(self): # 2 hdate values (= 1 hdate × 2 times pre-merged), 2 points → 2 coverages, each with 2 t-values @@ -341,5 +338,5 @@ def test_hdate_two_points_two_times(self): assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"] assert cov1["ranges"]["dis06"]["values"] == [38.91, 49.62] - assert "Forecast date" not in cov0["mars:metadata"] - assert "Forecast date" not in cov1["mars:metadata"] + assert cov0["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + assert cov1["mars:metadata"] == EXPECTED_REANALYSIS_METADATA From 4b45e1a569940c44a93d6b34e87daf52e77a37de Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:26:42 +0000 Subject: [PATCH 10/22] chore: run pre-commit hooks --- tests/data/test_timeseries_xyz_coverage.json | 2 +- tests/data/test_verticalprofile_coverage.json | 2 +- tests/data/test_verticalprofile_xyz_coverage.json | 2 +- tests/test_encoder_time_series_from_polytope.py | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) 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_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 4ea57b7..e420bbd 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -1,5 +1,4 @@ import numpy as np - from polytope_feature.datacube.datacube_axis import IntDatacubeAxis from polytope_feature.datacube.tensor_index_tree import TensorIndexTree @@ -75,7 +74,6 @@ def efas_branch(hdate, lat, lon, result): class TestTimeseriesFromPolytope: - def test_standard_forecast_single_point(self): # od/oper/fc/sfc, 1 point, param 167 (2t), steps 0 and 6 # axis ordering from a real polytope pprint From 0e6129e5a31a2fb5b7c537f3a123b88871759c6f Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:53:10 +0200 Subject: [PATCH 11/22] test: add further regression test for "normal" forecasts --- .../test_encoder_time_series_from_polytope.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index e420bbd..0c21d37 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -128,6 +128,85 @@ def test_standard_forecast_single_point(self): "levelist": 0, } + def test_standard_forecast_multiple_coverages(self): + # ce/efas/fc/sfc flood forecast: class=ce, stream=efas, type=fc, param=240023 (dis06) + # 2 dates (time 00:00 and 12:00 pre-merged), 2 steps (6, 30), 2 points + # → 4 coverages (2 dates × 2 points), each with 2 t-values from steps + 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) + + assert len(covjson["coverages"]) == 4 + + expected_metadata = { + "class": "ce", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "model": "lisflood", + "origin": "ecmf", + "stream": "efas", + "type": "fc", + "number": 0, + "levelist": 0, + } + + # point 1, date=2026-01-01T00:00 + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] + assert cov0["domain"]["axes"]["longitude"]["values"] == [6.5] + assert cov0["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z", "2026-01-02T06:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [12.5, 19.3] + + # point 1, date=2026-01-01T12:00 + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [51.5] + assert cov1["domain"]["axes"]["longitude"]["values"] == [6.5] + assert cov1["domain"]["axes"]["t"]["values"] == ["2026-01-01T18:00:00Z", "2026-01-02T18:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [15.8, 22.6] + + # point 2, date=2026-01-01T00:00 + cov2 = covjson["coverages"][2] + assert cov2["domain"]["axes"]["latitude"]["values"] == [52.0] + assert cov2["domain"]["axes"]["longitude"]["values"] == [7.0] + assert cov2["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z", "2026-01-02T06:00:00Z"] + assert cov2["ranges"]["dis06"]["values"] == [8.7, 14.1] + + # point 2, date=2026-01-01T12:00 + cov3 = covjson["coverages"][3] + assert cov3["domain"]["axes"]["latitude"]["values"] == [52.0] + assert cov3["domain"]["axes"]["longitude"]["values"] == [7.0] + assert cov3["domain"]["axes"]["t"]["values"] == ["2026-01-01T18:00:00Z", "2026-01-02T18:00:00Z"] + assert cov3["ranges"]["dis06"]["values"] == [10.2, 16.9] + + for cov in covjson["coverages"]: + assert cov["mars:metadata"]["class"] == "ce" + assert cov["mars:metadata"]["stream"] == "efas" + assert cov["mars:metadata"]["type"] == "fc" + assert cov["mars:metadata"]["model"] == "lisflood" + def test_hdate_reanalysis_single_point(self): # EFAS reanalysis: 1 hdate (with time pre-merged by polytope-mars), 1 point # polytope-mars merges time into hdate: pd.Timestamp("20250714T0600") → np.datetime64 From d77198205ed53ece6e6d465c790a545ceb7241c8 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:02:35 +0000 Subject: [PATCH 12/22] chore: run pre-commit hooks --- tests/test_encoder_time_series_from_polytope.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 0c21d37..fbbbd18 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -160,19 +160,6 @@ def test_standard_forecast_multiple_coverages(self): assert len(covjson["coverages"]) == 4 - expected_metadata = { - "class": "ce", - "domain": "g", - "expver": "0001", - "levtype": "sfc", - "model": "lisflood", - "origin": "ecmf", - "stream": "efas", - "type": "fc", - "number": 0, - "levelist": 0, - } - # point 1, date=2026-01-01T00:00 cov0 = covjson["coverages"][0] assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] From 6a3903927277ff285af4f98c14243dfa66542c20 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:35:08 +0000 Subject: [PATCH 13/22] feat: add experimental from_polytope_reforecast to TimeSeries encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since support for our hdate-based reanalysis data does seem to be a rare (and soon outdated) use case, this changes the tests and implementation to only focus on reforecast data in general. Users for old efas reanalysis data now just will not profit from automatic merging of hdate + time + step axis into a single dimension, as this could easily break reforecast retrieval patterns. Each hdate is an independent forecast initialisation, so one coverage per (point × hdate) is the correct reforecast semantic. Fix test expectations to match (coverage counts, per-coverage Forecast date), rename helpers for clarity, and add test_multiple_hdates_and_steps. --- covjsonkit/encoder/TimeSeries.py | 141 +++++++- covjsonkit/encoder/encoder.py | 47 ++- .../test_encoder_time_series_from_polytope.py | 331 ++++++++++++------ 3 files changed, 383 insertions(+), 136 deletions(-) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index b384e34..22a55d7 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -1,11 +1,15 @@ import logging import time +import typing from datetime import datetime, timedelta import pandas as pd from .encoder import Encoder +if typing.TYPE_CHECKING: + from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + class TimeSeries(Encoder): def __init__(self, type, domaintype): @@ -145,12 +149,6 @@ 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) - print("coords: ", coords) - print("fields: ", fields) - print("mars_metadata: ", mars_metadata) - print("range_dict: ", range_dict) - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") end = time.time() delta = end - start logging.debug("Tree walking ends at: %s", end) # noqa: E501 @@ -477,3 +475,134 @@ def from_polytope_step(self, result): logging.debug("Coverage creation: %s", delta) # noqa: E501 return self.covjson + + def from_polytope_reforecast(self, result: "TensorIndexTree") -> dict: + """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. + """ + coords = {} + mars_metadata = {} + range_dict = {} + fields = {} + fields["lat"] = 0 + fields["param"] = 0 + fields["number"] = [0] + fields["step"] = 0 + fields["dates"] = [] + fields["levels"] = [0] + + start = time.time() + logging.debug("Tree walking starts at: %s", start) # noqa: E501 + self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key="hdate") + end = time.time() + delta = end - start + logging.debug("Tree walking ends at: %s", end) # noqa: E501 + logging.debug("Tree walking takes: %s", delta) # noqa: E501 + + start = time.time() + logging.debug("Coords creation: %s", start) # noqa: E501 + + self.add_reference( + { + "coordinates": ["latitude", "longitude", "levelist"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + } + ) + + coordinates = {} + + levels = fields["levels"] + if fields["param"] == 0: + raise ValueError("No data was returned.") + for para in fields["param"]: + self.add_parameter(para) + + logging.debug("The parameters added were: %s", self.parameters) # noqa: E501 + + points = len(coords[fields["dates"][0]]["composite"]) + + for date in fields["dates"]: + coordinates[date] = [] + for i, point in enumerate(range(points)): + coordinates[date].append( + { + "latitude": [coords[date]["composite"][i][0]], + "longitude": [coords[date]["composite"][i][1]], + "levelist": [levels[0]], + } + ) + coordinates[date][i]["t"] = [] + for level in fields["levels"]: + for num in fields["number"]: + for para in fields["param"]: + for step in fields["step"]: + date_format = "%Y%m%dT%H%M%S" + new_date = pd.Timestamp(date).strftime(date_format) + start_time = datetime.strptime(new_date, date_format) + # add current date to list by converting it to iso format + if isinstance(step, timedelta): + stamp = start_time + step + else: + try: + int(step) + except ValueError: + step = step[0] + stamp = start_time + timedelta(hours=int(step)) + coordinates[date][i]["t"].append(stamp.isoformat() + "Z") + break + break + break + + logging.debug("Coordinates created: %s", coordinates) # noqa: E501 + + end = time.time() + delta = end - start + logging.debug("Coords creation: %s", end) # noqa: E501 + logging.debug("Coords creation: %s", delta) # noqa: E501 + + start = time.time() + logging.debug("Coverage creation: %s", start) # noqa: E501 + + logging.debug("The points found were: %s", points) # noqa: E501 + logging.debug("The fields retrieved were: %s", fields) # noqa: E501 + logging.debug("The range_dict created was: %s", range_dict) # noqa: E501 + + for i, point in enumerate(range(points)): + for date in fields["dates"]: + for level in fields["levels"]: + for num in fields["number"]: + val_dict = {} + for para in fields["param"]: + val_dict[para] = [] + for step in fields["step"]: + key = (date, level, num, para, step) + try: + val_dict[para].append(range_dict[key][i]) + except IndexError: + logging.debug( + f"Index {i} out of range for key {key} in range_dict. " + f"Available keys: {list(range_dict.keys())}" + ) + raise IndexError( + f"Key {key} not found in range_dict. " + f"Please ensure all axes are compressed in config" + ) + mm = mars_metadata.copy() + mm["number"] = num + mm["Forecast date"] = date + mm["levelist"] = level + coordinates[date][i]["levelist"] = [level] + del mm["step"] + self.add_coverage(mm, coordinates[date][i], val_dict) + + end = time.time() + delta = end - start + logging.debug("Coverage creation: %s", end) # noqa: E501 + logging.debug("Coverage creation: %s", delta) # noqa: E501 + + return self.covjson diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index 778f434..1770e61 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,21 +190,17 @@ 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] = {} coords[date]["composite"] = [] coords[date]["t"] = [date] return dates - if child.axis.name == "hdate": - hdates = [f"{hdate}Z" for hdate in child.values] - for hdate in hdates: - coords[hdate] = {} - coords[hdate]["composite"] = [] - coords[hdate]["t"] = [hdate] - return hdates if child.axis.name == "number": return child.values if child.axis.name == "step": @@ -209,9 +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 "hdate": - fields["dates"].extend(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 @@ -220,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): diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index fbbbd18..3f8d811 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -35,8 +35,8 @@ def make_point(lat, lon, result): return lat_n -# Axis ordering for EFAS reanalysis (between hdate and latitude in the tree) -EFAS_SUFFIX = [ +# Axis ordering for hdate reforecast (between hdate and latitude in the tree) +HDATE_SUFFIX = [ ("domain", ("g",)), ("expver", ("4321",)), ("levtype", ("sfc",)), @@ -49,18 +49,18 @@ def make_point(lat, lon, result): ] -def efas_branch(hdate, lat, lon, result): - """hdate → [EFAS_SUFFIX axes] → lat → lon(leaf). Single-point branch.""" +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 EFAS_SUFFIX], + *[node(n, v) for n, v in HDATE_SUFFIX], make_point(lat, lon, result), ) -EXPECTED_REANALYSIS_METADATA = { +EXPECTED_HDATE_METADATA = { "class": "ce", - "date": "2024-03-01", + "date": np.datetime64("2024-03-01"), "domain": "g", "expver": "4321", "levtype": "sfc", @@ -194,17 +194,18 @@ def test_standard_forecast_multiple_coverages(self): assert cov["mars:metadata"]["type"] == "fc" assert cov["mars:metadata"]["model"] == "lisflood" - def test_hdate_reanalysis_single_point(self): - # EFAS reanalysis: 1 hdate (with time pre-merged by polytope-mars), 1 point - # polytope-mars merges time into hdate: pd.Timestamp("20250714T0600") → np.datetime64 + +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"),)), - efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), + hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), ) - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] @@ -227,127 +228,97 @@ def test_hdate_reanalysis_single_point(self): } } - # TODO: does "Forecast date" make sense here? this is reanalysis, not a forecast. - # hdate is a hindcast reference date. might drop it or rename. discuss w/ Adam - # assert "Forecast date" not in cov["mars:metadata"] - assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - def test_hdate_multiple_times(self): - # 1 hdate × 2 times (pre-merged into 2 hdate values), 1 point → 1 coverage, 2 t-values + def test_multiple_times(self): + # 2 hdate values (pre-merged times), 1 point → 2 coverages (one per hdate) tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) - date.add_child(efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) - date.add_child(efas_branch(np.datetime64("2025-07-14T12:00:00"), 51.5, 6.5, [55.30])) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(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])) - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - 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 len(covjson["coverages"]) == 2 - assert cov["ranges"] == { - "dis06": { - "type": "NdArray", - "dataType": "float", - "shape": [2], - "axisNames": ["dis06"], - "values": [42.17, 55.30], - } - } + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [42.17] + assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [55.30] + assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} - def test_hdate_multiple_hdates(self): - # 2 hdates × 1 time (pre-merged), 1 point → 1 coverage, 2 t-values + def test_multiple_hdates(self): + # 2 hdates × 1 time (pre-merged), 1 point → 2 coverages (one per hdate) tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) - date.add_child(efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) - date.add_child(efas_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [55.30])) + 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(tree) - - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert cov["domain"]["axes"] == { - "latitude": {"values": [51.5]}, - "longitude": {"values": [6.5]}, - "levelist": {"values": [0]}, - "t": {"values": ["2025-07-14T12:00:00Z", "2025-07-15T12:00:00Z"]}, - } + assert len(covjson["coverages"]) == 2 - assert cov["ranges"] == { - "dis06": { - "type": "NdArray", - "dataType": "float", - "shape": [2], - "axisNames": ["dis06"], - "values": [42.17, 55.30], - } - } + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [42.17] + assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [55.30] + assert cov1["mars:metadata"] == {"Forecast date": "2025-07-15T06:00:00Z", **EXPECTED_HDATE_METADATA} - def test_hdate_multiple_times_and_hdates(self): - # 2 hdates × 2 times (pre-merged into 4 hdate values), 1 point → 1 coverage, 4 t-values + def test_multiple_times_and_hdates(self): + # 2 hdates × 2 times (pre-merged into 4 hdate values), 1 point → 4 coverages tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) - date.add_child(efas_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) - date.add_child(efas_branch(np.datetime64("2025-07-14T12:00:00"), 51.5, 6.5, [55.30])) - date.add_child(efas_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [61.44])) - date.add_child(efas_branch(np.datetime64("2025-07-15T12:00:00"), 51.5, 6.5, [73.82])) + 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])) + date.add_child(hdate_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [61.44])) + date.add_child(hdate_branch(np.datetime64("2025-07-15T12:00:00"), 51.5, 6.5, [73.82])) - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] + assert len(covjson["coverages"]) == 4 - assert cov["domain"]["axes"] == { - "latitude": {"values": [51.5]}, - "longitude": {"values": [6.5]}, - "levelist": {"values": [0]}, - "t": { - "values": [ - "2025-07-14T12:00:00Z", - "2025-07-15T12:00:00Z", - "2025-07-14T18:00:00Z", - "2025-07-15T18:00:00Z", - ] - }, - } + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [42.17] + assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - assert cov["ranges"] == { - "dis06": { - "type": "NdArray", - "dataType": "float", - "shape": [4], - "axisNames": ["dis06"], - "values": [42.17, 55.30, 61.44, 73.82], - } - } + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [55.30] + assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} + + cov2 = covjson["coverages"][2] + assert cov2["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z"] + assert cov2["ranges"]["dis06"]["values"] == [61.44] + assert cov2["mars:metadata"] == {"Forecast date": "2025-07-15T06:00:00Z", **EXPECTED_HDATE_METADATA} - assert cov["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + cov3 = covjson["coverages"][3] + assert cov3["domain"]["axes"]["t"]["values"] == ["2025-07-15T18:00:00Z"] + assert cov3["ranges"]["dis06"]["values"] == [73.82] + assert cov3["mars:metadata"] == {"Forecast date": "2025-07-15T12:00:00Z", **EXPECTED_HDATE_METADATA} - def test_hdate_two_points(self): + def test_two_points(self): # 1 hdate (pre-merged with time), 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 EFAS_SUFFIX], + *[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(tree) + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) assert len(covjson["coverages"]) == 2 @@ -363,13 +334,11 @@ def test_hdate_two_points(self): assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] assert cov1["ranges"]["dis06"]["values"] == [38.91] - assert cov0["mars:metadata"] == EXPECTED_REANALYSIS_METADATA - assert cov1["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - def test_hdate_two_points_two_times(self): - # 2 hdate values (= 1 hdate × 2 times pre-merged), 2 points → 2 coverages, each with 2 t-values - # TODO: in the future we might want MultiPointSeries support to emit a single - # coverage with a composite spatial axis instead of one coverage per point + def test_two_points_two_times(self): + # 2 hdate values (= 1 hdate × 2 times pre-merged), 2 points → 4 coverages (point × hdate) tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) date = tip(tree) @@ -379,28 +348,158 @@ def test_hdate_two_points_two_times(self): ]: branch = chain( node("hdate", (hdate_val,)), - *[node(n, v) for n, v in EFAS_SUFFIX], + *[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(tree) + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 + assert len(covjson["coverages"]) == 4 cov0 = covjson["coverages"][0] assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] - assert cov0["domain"]["axes"]["longitude"]["values"] == [6.5] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov0["ranges"]["dis06"]["values"] == [42.17] + assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [51.5] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [55.30] + assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} + + cov2 = covjson["coverages"][2] + assert cov2["domain"]["axes"]["latitude"]["values"] == [52.0] + assert cov2["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov2["ranges"]["dis06"]["values"] == [38.91] + assert cov2["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + cov3 = covjson["coverages"][3] + assert cov3["domain"]["axes"]["latitude"]["values"] == [52.0] + assert cov3["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] + assert cov3["ranges"]["dis06"]["values"] == [49.62] + assert cov3["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_multiple_steps(self): + """Single hdate, two steps (6h, 12h), single point.""" + 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"]["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"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov["ranges"]["dis06"]["values"] == [42.17] + assert cov["ranges"]["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) + + assert len(covjson["coverages"]) == 4 + + cov0 = covjson["coverages"][0] assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [42.17, 55.30] + assert cov0["ranges"]["dis06"]["values"] == [10.0, 20.0] + assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [52.0] - assert cov1["domain"]["axes"]["longitude"]["values"] == [7.0] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [38.91, 49.62] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z", "2025-07-15T00:00:00Z"] + assert cov1["ranges"]["dis06"]["values"] == [30.0, 40.0] + assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} + + cov2 = covjson["coverages"][2] + assert cov2["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z", "2025-07-15T18:00:00Z"] + assert cov2["ranges"]["dis06"]["values"] == [50.0, 60.0] + assert cov2["mars:metadata"] == {"Forecast date": "2025-07-15T06:00:00Z", **EXPECTED_HDATE_METADATA} - assert cov0["mars:metadata"] == EXPECTED_REANALYSIS_METADATA - assert cov1["mars:metadata"] == EXPECTED_REANALYSIS_METADATA + cov3 = covjson["coverages"][3] + assert cov3["domain"]["axes"]["t"]["values"] == ["2025-07-15T18:00:00Z", "2025-07-16T00:00:00Z"] + assert cov3["ranges"]["dis06"]["values"] == [70.0, 80.0] + assert cov3["mars:metadata"] == {"Forecast date": "2025-07-15T12:00:00Z", **EXPECTED_HDATE_METADATA} From eaaa07819bdab145e83524b5f83a249935adc56c Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:45:25 +0200 Subject: [PATCH 14/22] feat: add from_polytope_reforecast to all encoders - Add from_polytope_reforecast to Encoder ABC - Remove inline hdate hacks from from_polytope in all 8 encoders that had them - Implement from_polytope_reforecast on all 9 non-TimeSeries encoders by delegating to from_polytope(result, date_key="hdate") - Add from_polytope regression tests for all 10 encoder types - Add from_polytope_reforecast tests for all 10 encoder types - Extract shared test helpers into tests/conftest.py --- covjsonkit/encoder/BoundingBox.py | 9 +- covjsonkit/encoder/Circle.py | 9 +- covjsonkit/encoder/Frame.py | 9 +- covjsonkit/encoder/Grid.py | 10 +- covjsonkit/encoder/Path.py | 9 +- covjsonkit/encoder/Position.py | 10 +- covjsonkit/encoder/Shapefile.py | 10 +- covjsonkit/encoder/VerticalProfile.py | 10 +- covjsonkit/encoder/Wkt.py | 7 +- covjsonkit/encoder/encoder.py | 5 +- tests/conftest.py | 38 +++ ...test_encoder_bounding_box_from_polytope.py | 233 ++++++++++++++++ tests/test_encoder_circle_from_polytope.py | 162 +++++++++++ tests/test_encoder_frame_from_polytope.py | 138 ++++++++++ tests/test_encoder_grid_from_polytope.py | 191 +++++++++++++ tests/test_encoder_path_from_polytope.py | 184 +++++++++++++ tests/test_encoder_position_from_polytope.py | 214 +++++++++++++++ tests/test_encoder_shapefile_from_polytope.py | 138 ++++++++++ .../test_encoder_time_series_from_polytope.py | 33 +-- ..._encoder_vertical_profile_from_polytope.py | 257 ++++++++++++++++++ tests/test_encoder_wkt_from_polytope.py | 139 ++++++++++ 21 files changed, 1744 insertions(+), 71 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_encoder_bounding_box_from_polytope.py create mode 100644 tests/test_encoder_circle_from_polytope.py create mode 100644 tests/test_encoder_frame_from_polytope.py create mode 100644 tests/test_encoder_grid_from_polytope.py create mode 100644 tests/test_encoder_path_from_polytope.py create mode 100644 tests/test_encoder_position_from_polytope.py create mode 100644 tests/test_encoder_shapefile_from_polytope.py create mode 100644 tests/test_encoder_vertical_profile_from_polytope.py create mode 100644 tests/test_encoder_wkt_from_polytope.py diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index 5fcde2c..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,9 +130,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") + 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 +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 ba58736..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,10 +127,8 @@ 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) - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") 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 @@ -202,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 28c285d..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,10 +127,8 @@ 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) - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") 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 @@ -197,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 c79e5f8..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,10 +133,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) - - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") + 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 @@ -220,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 2df47a8..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,10 +129,8 @@ 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 "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") if len(fields["l"]) == 0: fields["l"] = [0] @@ -227,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 c0ca5eb..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,15 +144,12 @@ 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 logging.debug("Tree walking takes: %s", delta) # noqa: E501 - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") - start = time.time() logging.debug("Coords creation: %s", start) # noqa: E501 @@ -254,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 827d833..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,10 +127,7 @@ def from_polytope(self, result): fields["dates"] = [] fields["levels"] = [0] - self.walk_tree(result, fields, coords, mars_metadata, range_dict) - - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") + 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 @@ -198,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/VerticalProfile.py b/covjsonkit/encoder/VerticalProfile.py index 3c12c60..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,15 +135,12 @@ 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 logging.debug("Tree walking takes: %s", delta) # noqa: E501 - if "hdate" in mars_metadata: - fields["dates"].remove(mars_metadata["Forecast date"] + "Z") - start = time.time() logging.debug("Coords creation: %s", start) # noqa: E501 @@ -242,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 1770e61..9be7000 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -557,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/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/test_encoder_bounding_box_from_polytope.py b/tests/test_encoder_bounding_box_from_polytope.py new file mode 100644 index 0000000..4ed18df --- /dev/null +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -0,0 +1,233 @@ +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 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["latitude", "longitude", "levelist"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + + # Ranges + assert "2t" in cov["ranges"] + r = cov["ranges"]["2t"] + assert r["type"] == "NdArray" + assert r["dataType"] == "float" + assert r["shape"] == [2] + assert r["axisNames"] == ["2t"] + assert r["values"] == [264.9, 265.1] + + # Metadata + mm = cov["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["domain"] == "g" + assert mm["expver"] == "0001" + assert mm["levtype"] == "sfc" + assert mm["stream"] == "oper" + assert mm["type"] == "fc" + assert mm["number"] == 0 + assert mm["step"] == 0 + + def test_two_dates_two_steps_two_points(self): + 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) + + # 2 dates × 2 steps = 4 coverages + assert len(covjson["coverages"]) == 4 + + # Collect coverages keyed by (date, step) + by_key = {} + for cov in covjson["coverages"]: + mm = cov["mars:metadata"] + by_key[(mm["Forecast date"], mm["step"])] = cov + + # Date 1, step 0 + cov = by_key[("2025-01-01T00:00:00Z", 0)] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + assert cov["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + + # Date 1, step 6 + cov = by_key[("2025-01-01T00:00:00Z", 6)] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + assert cov["ranges"]["2t"]["values"] == [270.1, 271.3] + + # Date 2, step 0 + cov = by_key[("2025-01-02T00:00:00Z", 0)] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z"] + assert cov["ranges"]["2t"]["values"] == [266.0, 267.0] + + # Date 2, step 6 + cov = by_key[("2025-01-02T00:00:00Z", 6)] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z"] + assert cov["ranges"]["2t"]["values"] == [272.0, 273.0] + + +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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["latitude", "longitude", "levelist"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + + mm = cov["mars:metadata"] + assert mm["Forecast date"] == "2025-07-14T06:00:00Z" + + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + + 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) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] + assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov1["ranges"]["2t"]["values"] == [266.0, 267.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) + + assert len(covjson["coverages"]) == 2 + + by_step = {} + for cov in covjson["coverages"]: + by_step[cov["mars:metadata"]["step"]] = cov + + cov0 = by_step[0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] + assert cov0["mars:metadata"]["Forecast date"] == "2025-07-14T06:00:00Z" + + cov6 = by_step[6] + assert cov6["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov6["ranges"]["2t"]["values"] == [270.1, 271.3] + assert cov6["mars:metadata"]["Forecast date"] == "2025-07-14T06:00:00Z" diff --git a/tests/test_encoder_circle_from_polytope.py b/tests/test_encoder_circle_from_polytope.py new file mode 100644 index 0000000..194e403 --- /dev/null +++ b/tests/test_encoder_circle_from_polytope.py @@ -0,0 +1,162 @@ +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 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["latitude", "longitude", "levelist"] + assert composite["values"] == [ + [48.0, 11.0, 0], + [49.0, 11.5, 0], + [50.0, 12.0, 0], + ] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + + # Ranges + r = cov["ranges"]["2t"] + assert r["type"] == "NdArray" + assert r["dataType"] == "float" + assert r["shape"] == [3] + assert r["axisNames"] == ["2t"] + assert r["values"] == [264.9, 265.5, 266.1] + + # Metadata + mm = cov["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["stream"] == "oper" + assert mm["type"] == "fc" + assert mm["number"] == 0 + assert mm["step"] == 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["latitude", "longitude", "levelist"] + assert composite["values"] == [ + [48.0, 11.0, 0], + [49.0, 11.5, 0], + [50.0, 12.0, 0], + ] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + + mm = cov["mars:metadata"] + assert mm["Forecast date"] == "2025-07-14T06:00:00Z" + + r = cov["ranges"]["2t"] + assert r["type"] == "NdArray" + assert r["dataType"] == "float" + assert r["shape"] == [3] + assert r["axisNames"] == ["2t"] + assert r["values"] == [264.9, 265.5, 266.1] + + 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) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov0["domain"]["axes"]["composite"]["values"] == [ + [48.0, 11.0, 0], + [49.0, 11.5, 0], + [50.0, 12.0, 0], + ] + assert cov0["ranges"]["2t"]["values"] == [264.9, 265.5, 266.1] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] + assert cov1["domain"]["axes"]["composite"]["values"] == [ + [48.0, 11.0, 0], + [49.0, 11.5, 0], + [50.0, 12.0, 0], + ] + assert cov1["ranges"]["2t"]["values"] == [270.0, 271.0, 272.0] diff --git a/tests/test_encoder_frame_from_polytope.py b/tests/test_encoder_frame_from_polytope.py new file mode 100644 index 0000000..e9b04e7 --- /dev/null +++ b/tests/test_encoder_frame_from_polytope.py @@ -0,0 +1,138 @@ +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 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain — Frame uses ["x", "y", "z"] coordinates + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["x", "y", "z"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + + # Ranges + r = cov["ranges"]["2t"] + assert r["type"] == "NdArray" + assert r["dataType"] == "float" + assert r["shape"] == [2] + assert r["axisNames"] == ["2t"] + assert r["values"] == [264.9, 265.1] + + # Metadata + mm = cov["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["stream"] == "oper" + assert mm["type"] == "fc" + assert mm["number"] == 0 + assert mm["step"] == 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["x", "y", "z"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + + mm = cov["mars:metadata"] + assert mm["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) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] + assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] diff --git a/tests/test_encoder_grid_from_polytope.py b/tests/test_encoder_grid_from_polytope.py new file mode 100644 index 0000000..f9608cc --- /dev/null +++ b/tests/test_encoder_grid_from_polytope.py @@ -0,0 +1,191 @@ +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 TestGridFromPolytope: + """Tests for Grid encoder's from_polytope method.""" + + def _build_grid_tree(self, grid_points, param="167", step=0): + """Build a grid tree. + + grid_points: list of (lat, lon, result_value) triples. + Each is a separate lat→lon subtree under the common metadata chain. + """ + 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", ("an",)), + ) + parent = tip(tree) + for lat, lon, vals in grid_points: + if not isinstance(vals, list): + vals = [vals] + parent.add_child(make_point(lat, lon, vals)) + + return tree + + 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 = self._build_grid_tree(grid_points) + encoder = Covjsonkit().encode("CoverageCollection", "Grid") + covjson = encoder.from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "Grid" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain axes + axes = cov["domain"]["axes"] + assert axes["t"]["values"] == [0] + assert axes["levelist"]["values"] == [0] + assert axes["latitude"]["values"] == [48.0, 50.0] + assert axes["longitude"]["values"] == [11.0, 12.0] + + # Range + assert "2t" in cov["ranges"] + rng = cov["ranges"]["2t"] + assert rng["type"] == "NdArray" + assert rng["dataType"] == "float" + assert rng["axisNames"] == ["t", "levelist", "latitude", "longitude"] + assert rng["shape"] == [1, 1, 2, 2] + assert rng["values"] == [264.9, 265.1, 266.3, 267.5] + + def test_1x1_grid(self): + """1×1 grid: single point.""" + grid_points = [(48.0, 11.0, [264.9])] + tree = self._build_grid_tree(grid_points) + encoder = Covjsonkit().encode("CoverageCollection", "Grid") + covjson = encoder.from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["latitude"]["values"] == [48.0] + assert cov["domain"]["axes"]["longitude"]["values"] == [11.0] + assert cov["ranges"]["2t"]["shape"] == [1, 1, 1, 1] + assert cov["ranges"]["2t"]["values"] == [264.9] + + def test_metadata(self): + """mars:metadata should be populated correctly.""" + grid_points = [(48.0, 11.0, [264.9])] + tree = self._build_grid_tree(grid_points) + encoder = Covjsonkit().encode("CoverageCollection", "Grid") + covjson = encoder.from_polytope(tree) + + mm = covjson["coverages"][0]["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["number"] == 0 + + def test_referencing(self): + """Check the CRS referencing block.""" + grid_points = [(48.0, 11.0, [264.9])] + tree = self._build_grid_tree(grid_points) + encoder = Covjsonkit().encode("CoverageCollection", "Grid") + covjson = encoder.from_polytope(tree) + + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["latitude", "longitude", "levelist"] + assert ref["system"]["type"] == "GeographicCRS" + + def test_parameters_block(self): + """Top-level parameters dict should have param 167 = '2t'.""" + grid_points = [(48.0, 11.0, [264.9])] + tree = self._build_grid_tree(grid_points) + encoder = Covjsonkit().encode("CoverageCollection", "Grid") + covjson = encoder.from_polytope(tree) + + assert "2t" in covjson["parameters"] + p = covjson["parameters"]["2t"] + assert p["type"] == "Parameter" + + +class TestGridFromPolytopeReforecast: + """Tests for Grid encoder's from_polytope_reforecast method.""" + + def test_reforecast_single_hdate_2x2_grid(self): + """Single hdate with 2×2 grid → 1 Grid 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(48.0, 12.0, [265.1])) + fc.add_child(make_point(50.0, 11.0, [266.3])) + fc.add_child(make_point(50.0, 12.0, [267.5])) + 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] + axes = cov["domain"]["axes"] + assert len(axes["latitude"]["values"]) == 2 + assert len(axes["longitude"]["values"]) == 2 + assert axes["t"]["values"] == [0] + + rng = cov["ranges"]["2t"] + assert rng["shape"] == [1, 1, 2, 2] + assert rng["values"] == [264.9, 265.1, 266.3, 267.5] + + def test_reforecast_two_hdates_2x2_grid(self): + """Two hdates each with 2×2 grid → 2 Grid 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(48.0, 12.0, [265.1])) + fc.add_child(make_point(50.0, 11.0, [266.3])) + fc.add_child(make_point(50.0, 12.0, [267.5])) + date_node.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 2 diff --git a/tests/test_encoder_path_from_polytope.py b/tests/test_encoder_path_from_polytope.py new file mode 100644 index 0000000..3de8fd6 --- /dev/null +++ b/tests/test_encoder_path_from_polytope.py @@ -0,0 +1,184 @@ +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) + encoder = Covjsonkit().encode("CoverageCollection", "Path") + covjson = encoder.from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "Trajectory" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain: composite tuples [step, lat, lon, level] + comp = cov["domain"]["axes"]["composite"] + assert comp["dataType"] == "tuple" + assert comp["coordinates"] == ["t", "x", "y", "z"] + assert len(comp["values"]) == 2 + assert comp["values"][0] == [0, 48.0, 11.0, 0] + assert comp["values"][1] == [0, 49.0, 12.0, 0] + + # Range + assert "2t" in cov["ranges"] + rng = cov["ranges"]["2t"] + assert rng["type"] == "NdArray" + assert rng["dataType"] == "float" + assert rng["shape"] == [2] + assert rng["values"] == [264.9, 265.1] + + # Metadata: levelist should be removed + mm = cov["mars:metadata"] + assert "levelist" not in mm + assert mm["number"] == 0 + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + + def test_three_points_along_path(self): + """3 points along a path → composite has 3 tuples.""" + points = [ + (48.0, 11.0, [264.9]), + (49.0, 12.0, [265.1]), + (50.0, 13.0, [266.3]), + ] + tree = self._build_path_tree(points) + encoder = Covjsonkit().encode("CoverageCollection", "Path") + covjson = encoder.from_polytope(tree) + + cov = covjson["coverages"][0] + assert len(cov["domain"]["axes"]["composite"]["values"]) == 3 + assert cov["ranges"]["2t"]["shape"] == [3] + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1, 266.3] + + def test_referencing(self): + """Check the CRS referencing block uses [t, x, y, z].""" + points = [(48.0, 11.0, [264.9])] + tree = self._build_path_tree(points) + encoder = Covjsonkit().encode("CoverageCollection", "Path") + covjson = encoder.from_polytope(tree) + + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["t", "x", "y", "z"] + assert ref["system"]["type"] == "GeographicCRS" + + def test_parameters_block(self): + """Top-level parameters dict should have param 167 = '2t'.""" + points = [(48.0, 11.0, [264.9])] + tree = self._build_path_tree(points) + encoder = Covjsonkit().encode("CoverageCollection", "Path") + covjson = encoder.from_polytope(tree) + + assert "2t" in covjson["parameters"] + p = covjson["parameters"]["2t"] + assert p["type"] == "Parameter" + + +class TestPathFromPolytopeReforecast: + """Tests for Path (Trajectory) encoder's from_polytope_reforecast method.""" + + 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] + comp = cov["domain"]["axes"]["composite"] + assert comp["dataType"] == "tuple" + assert comp["coordinates"] == ["t", "x", "y", "z"] + assert comp["values"] == [[0, 48.0, 11.0, 0], [0, 50.0, 12.0, 0]] + + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + + mm = cov["mars:metadata"] + assert "Forecast date" in mm + assert "2025-07-14" in mm["Forecast date"] + + 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) + + assert len(covjson["coverages"]) == 2 diff --git a/tests/test_encoder_position_from_polytope.py b/tests/test_encoder_position_from_polytope.py new file mode 100644 index 0000000..f7a9843 --- /dev/null +++ b/tests/test_encoder_position_from_polytope.py @@ -0,0 +1,214 @@ +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) + encoder = Covjsonkit().encode("CoverageCollection", "Position") + covjson = encoder.from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "PointSeries" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain axes + axes = cov["domain"]["axes"] + assert axes["latitude"]["values"] == [48.0] + assert axes["longitude"]["values"] == [11.0] + assert axes["levelist"]["values"] == [0] + assert axes["t"]["values"] == [ + "2025-01-01T00:00:00Z", + "2025-01-01T06:00:00Z", + ] + + # Range + assert "2t" in cov["ranges"] + rng = cov["ranges"]["2t"] + assert rng["type"] == "NdArray" + assert rng["dataType"] == "float" + assert rng["shape"] == [2] + assert rng["values"] == [264.9, 263.8] + + # Metadata: "step" key should be deleted by from_polytope + mm = cov["mars:metadata"] + assert "step" not in mm + assert mm["number"] == 0 + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + + 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) + encoder = Covjsonkit().encode("CoverageCollection", "Position") + covjson = encoder.from_polytope(tree) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["latitude"]["values"] == [48.0] + assert cov0["domain"]["axes"]["longitude"]["values"] == [11.0] + assert cov0["ranges"]["2t"]["values"] == [264.9, 263.8] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [50.0] + assert cov1["domain"]["axes"]["longitude"]["values"] == [13.0] + assert cov1["ranges"]["2t"]["values"] == [265.1, 264.2] + + def test_single_step(self): + """Single step → t has just 1 value.""" + points = [(48.0, 11.0, [264.9])] + tree = self._build_position_tree(points, steps=(0,)) + encoder = Covjsonkit().encode("CoverageCollection", "Position") + covjson = encoder.from_polytope(tree) + + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + assert cov["ranges"]["2t"]["values"] == [264.9] + assert cov["ranges"]["2t"]["shape"] == [1] + + def test_referencing(self): + """Check the CRS referencing block.""" + points = [(48.0, 11.0, [264.9])] + tree = self._build_position_tree(points, steps=(0,)) + encoder = Covjsonkit().encode("CoverageCollection", "Position") + covjson = encoder.from_polytope(tree) + + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["latitude", "longitude", "levelist"] + assert ref["system"]["type"] == "GeographicCRS" + + def test_parameters_block(self): + """Top-level parameters dict should have param 167 = '2t'.""" + points = [(48.0, 11.0, [264.9])] + tree = self._build_position_tree(points, steps=(0,)) + encoder = Covjsonkit().encode("CoverageCollection", "Position") + covjson = encoder.from_polytope(tree) + + assert "2t" in covjson["parameters"] + p = covjson["parameters"]["2t"] + assert p["type"] == "Parameter" + + +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) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "PointSeries" + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + axes0 = cov0["domain"]["axes"] + assert axes0["latitude"]["values"] == [48.0] + assert axes0["longitude"]["values"] == [11.0] + # t = hdate(06:00) + step(0) = 06:00 + assert axes0["t"]["values"] == ["2025-07-14T06:00:00Z"] + + cov1 = covjson["coverages"][1] + axes1 = cov1["domain"]["axes"] + assert axes1["latitude"]["values"] == [50.0] + assert axes1["longitude"]["values"] == [12.0] + assert axes1["t"]["values"] == ["2025-07-14T06:00:00Z"] + + 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) + + # hdate 1 + hdate1 = chain( + 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",)), + ) + fc1 = tip(hdate1) + fc1.add_child(make_point(48.0, 11.0, [264.9])) + fc1.add_child(make_point(50.0, 12.0, [265.1])) + + # hdate 2 + hdate2 = chain( + node("hdate", (np.datetime64("2025-07-15T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + ) + fc2 = tip(hdate2) + fc2.add_child(make_point(48.0, 11.0, [266.0])) + fc2.add_child(make_point(50.0, 12.0, [267.0])) + + date_node.add_child(hdate1) + date_node.add_child(hdate2) + + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 4 diff --git a/tests/test_encoder_shapefile_from_polytope.py b/tests/test_encoder_shapefile_from_polytope.py new file mode 100644 index 0000000..e5f1e42 --- /dev/null +++ b/tests/test_encoder_shapefile_from_polytope.py @@ -0,0 +1,138 @@ +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 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain — Shapefile uses ["x", "y", "z"] coordinates + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["x", "y", "z"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + + # Ranges + r = cov["ranges"]["2t"] + assert r["type"] == "NdArray" + assert r["dataType"] == "float" + assert r["shape"] == [2] + assert r["axisNames"] == ["2t"] + assert r["values"] == [264.9, 265.1] + + # Metadata + mm = cov["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["stream"] == "oper" + assert mm["type"] == "fc" + assert mm["number"] == 0 + assert mm["step"] == 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["x", "y", "z"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + + mm = cov["mars:metadata"] + assert mm["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", "Shapefile").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] + assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 3f8d811..25e224c 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -1,40 +1,9 @@ import numpy as np -from polytope_feature.datacube.datacube_axis import IntDatacubeAxis +from conftest import chain, make_leaf, make_point, node, tip from polytope_feature.datacube.tensor_index_tree import TensorIndexTree from covjsonkit.api import Covjsonkit - -def node(name, values): - ax = IntDatacubeAxis() - ax.name = name - return TensorIndexTree(axis=ax, values=tuple(values)) - - -def chain(*nodes): - for a, b in zip(nodes, nodes[1:]): - a.add_child(b) - return nodes[0] - - -def tip(tree): - while tree.children: - tree = tree.children[0] - return tree - - -def make_leaf(lon, result): - leaf = node("longitude", (lon,)) - leaf.result = [np.float64(r) for r in result] - return leaf - - -def make_point(lat, lon, result): - lat_n = node("latitude", (lat,)) - lat_n.add_child(make_leaf(lon, result)) - return lat_n - - # Axis ordering for hdate reforecast (between hdate and latitude in the tree) HDATE_SUFFIX = [ ("domain", ("g",)), 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..94814f4 --- /dev/null +++ b/tests/test_encoder_vertical_profile_from_polytope.py @@ -0,0 +1,257 @@ +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() + encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") + covjson = encoder.from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "VerticalProfile" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain axes + axes = cov["domain"]["axes"] + assert axes["latitude"]["values"] == [48.0] + assert axes["longitude"]["values"] == [11.0] + assert axes["levelist"]["values"] == [1000, 850, 500] + assert axes["t"]["values"] == ["2025-01-01T00:00:00Z"] + + # Range + assert "t" in cov["ranges"] + rng = cov["ranges"]["t"] + assert rng["type"] == "NdArray" + assert rng["dataType"] == "float" + assert rng["axisNames"] == ["levelist"] + assert rng["shape"] == [3] + assert rng["values"] == [290.1, 280.2, 250.3] + + # Metadata + mm = cov["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["number"] == 0 + assert mm["step"] == 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) + # Point 1: lat=48, lon=11 + lev_node.add_child(make_point(48.0, 11.0, [290.1, 250.3])) + # Point 2: lat=50, lon=13 + lev_node.add_child(make_point(50.0, 13.0, [288.5, 248.7])) + + encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") + covjson = encoder.from_polytope(tree) + + assert len(covjson["coverages"]) == 2 + + # First coverage = first point + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["latitude"]["values"] == [48.0] + assert cov0["domain"]["axes"]["longitude"]["values"] == [11.0] + assert cov0["domain"]["axes"]["levelist"]["values"] == [1000, 500] + assert cov0["ranges"]["t"]["values"] == [290.1, 250.3] + assert cov0["ranges"]["t"]["shape"] == [2] + + # Second coverage = second point + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [50.0] + assert cov1["domain"]["axes"]["longitude"]["values"] == [13.0] + assert cov1["ranges"]["t"]["values"] == [288.5, 248.7] + + def test_single_level(self): + """Edge case: only 1 pressure level.""" + tree = self._build_vp_tree(levels_values={500: 250.3}) + encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") + covjson = encoder.from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["levelist"]["values"] == [500] + assert cov["ranges"]["t"]["values"] == [250.3] + assert cov["ranges"]["t"]["shape"] == [1] + + 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}) + encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") + covjson = encoder.from_polytope(tree) + + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T06:00:00Z"] + assert cov["mars:metadata"]["step"] == 6 + + def test_referencing(self): + """Check the CRS referencing block.""" + tree = self._build_vp_tree(levels_values={1000: 290.0}) + encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") + covjson = encoder.from_polytope(tree) + + ref = covjson["referencing"][0] + assert ref["coordinates"] == ["latitude", "longitude", "levelist"] + assert ref["system"]["type"] == "GeographicCRS" + + def test_parameters_block(self): + """The top-level parameters dict should contain param 130 = 't'.""" + tree = self._build_vp_tree(levels_values={1000: 290.0}) + encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") + covjson = encoder.from_polytope(tree) + + assert "t" in covjson["parameters"] + p = covjson["parameters"]["t"] + assert p["type"] == "Parameter" + assert "Temperature" in p["observedProperty"]["label"]["en"] + + +class TestVerticalProfileFromPolytopeReforecast: + """Tests for VerticalProfile encoder's from_polytope_reforecast method.""" + + 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("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("pl",)), + node("param", ("130",)), + node("step", (6,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + 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["type"] == "Coverage" + + axes = cov["domain"]["axes"] + assert axes["levelist"]["values"] == [1000, 850, 500] + assert axes["latitude"]["values"] == [48.0] + assert axes["longitude"]["values"] == [11.0] + # t = hdate(06:00) + step(6h) = 12:00 + assert axes["t"]["values"] == ["2025-07-14T12:00:00Z"] + + assert "t" in cov["ranges"] + rng = cov["ranges"]["t"] + assert rng["type"] == "NdArray" + assert rng["dataType"] == "float" + assert rng["shape"] == [3] + assert rng["values"] == [290.1, 280.2, 250.3] + + 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) + + # hdate 1 + hdate1 = chain( + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("pl",)), + node("param", ("130",)), + node("step", (6,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + node("levelist", (1000, 850, 500)), + node("latitude", (48.0,)), + make_leaf(11.0, [290.1, 280.2, 250.3]), + ) + + # hdate 2 + hdate2 = chain( + node("hdate", (np.datetime64("2025-07-15T06:00:00"),)), + node("domain", ("g",)), + node("expver", ("4321",)), + node("levtype", ("pl",)), + node("param", ("130",)), + node("step", (6,)), + node("stream", ("efcl",)), + node("type", ("sfo",)), + node("levelist", (1000, 850, 500)), + node("latitude", (48.0,)), + make_leaf(11.0, [291.0, 281.0, 251.0]), + ) + + date_node.add_child(hdate1) + date_node.add_child(hdate2) + + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert cov0["ranges"]["t"]["values"] == [290.1, 280.2, 250.3] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z"] + assert cov1["ranges"]["t"]["values"] == [291.0, 281.0, 251.0] diff --git a/tests/test_encoder_wkt_from_polytope.py b/tests/test_encoder_wkt_from_polytope.py new file mode 100644 index 0000000..d9d78e9 --- /dev/null +++ b/tests/test_encoder_wkt_from_polytope.py @@ -0,0 +1,139 @@ +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 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])) + + # Wkt encoder is dispatched via "Polygon" + covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope(tree) + + assert covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + # Domain — Wkt uses ["x", "y", "z"] coordinates + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["x", "y", "z"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + + # Ranges + r = cov["ranges"]["2t"] + assert r["type"] == "NdArray" + assert r["dataType"] == "float" + assert r["shape"] == [2] + assert r["axisNames"] == ["2t"] + assert r["values"] == [264.9, 265.1] + + # Metadata + mm = cov["mars:metadata"] + assert mm["class"] == "od" + assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + assert mm["stream"] == "oper" + assert mm["type"] == "fc" + assert mm["number"] == 0 + assert mm["step"] == 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 covjson["type"] == "CoverageCollection" + assert covjson["domainType"] == "MultiPoint" + assert len(covjson["coverages"]) == 1 + + cov = covjson["coverages"][0] + assert cov["type"] == "Coverage" + + composite = cov["domain"]["axes"]["composite"] + assert composite["dataType"] == "tuple" + assert composite["coordinates"] == ["x", "y", "z"] + assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + + assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + + mm = cov["mars:metadata"] + assert mm["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", "Polygon").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 2 + + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] + + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] + assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] + assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] From 8c5eae5561fd53070ed911b01b50a7ab5981e7c3 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:16:19 +0200 Subject: [PATCH 15/22] refactor: remove duplication in TimeSeries.from_polytope_reforecast --- covjsonkit/encoder/TimeSeries.py | 135 +----------------- .../test_encoder_time_series_from_polytope.py | 109 ++++++++++++++ 2 files changed, 113 insertions(+), 131 deletions(-) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 22a55d7..9a13924 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -1,15 +1,11 @@ import logging import time -import typing from datetime import datetime, timedelta import pandas as pd from .encoder import Encoder -if typing.TYPE_CHECKING: - from polytope_feature.datacube.tensor_index_tree import TensorIndexTree - class TimeSeries(Encoder): def __init__(self, type, domaintype): @@ -127,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: @@ -148,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 @@ -476,133 +472,10 @@ def from_polytope_step(self, result): return self.covjson - def from_polytope_reforecast(self, result: "TensorIndexTree") -> dict: + 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. """ - coords = {} - mars_metadata = {} - range_dict = {} - fields = {} - fields["lat"] = 0 - fields["param"] = 0 - fields["number"] = [0] - fields["step"] = 0 - fields["dates"] = [] - fields["levels"] = [0] - - start = time.time() - logging.debug("Tree walking starts at: %s", start) # noqa: E501 - self.walk_tree(result, fields, coords, mars_metadata, range_dict, date_key="hdate") - end = time.time() - delta = end - start - logging.debug("Tree walking ends at: %s", end) # noqa: E501 - logging.debug("Tree walking takes: %s", delta) # noqa: E501 - - start = time.time() - logging.debug("Coords creation: %s", start) # noqa: E501 - - self.add_reference( - { - "coordinates": ["latitude", "longitude", "levelist"], - "system": { - "type": "GeographicCRS", - "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", - }, - } - ) - - coordinates = {} - - levels = fields["levels"] - if fields["param"] == 0: - raise ValueError("No data was returned.") - for para in fields["param"]: - self.add_parameter(para) - - logging.debug("The parameters added were: %s", self.parameters) # noqa: E501 - - points = len(coords[fields["dates"][0]]["composite"]) - - for date in fields["dates"]: - coordinates[date] = [] - for i, point in enumerate(range(points)): - coordinates[date].append( - { - "latitude": [coords[date]["composite"][i][0]], - "longitude": [coords[date]["composite"][i][1]], - "levelist": [levels[0]], - } - ) - coordinates[date][i]["t"] = [] - for level in fields["levels"]: - for num in fields["number"]: - for para in fields["param"]: - for step in fields["step"]: - date_format = "%Y%m%dT%H%M%S" - new_date = pd.Timestamp(date).strftime(date_format) - start_time = datetime.strptime(new_date, date_format) - # add current date to list by converting it to iso format - if isinstance(step, timedelta): - stamp = start_time + step - else: - try: - int(step) - except ValueError: - step = step[0] - stamp = start_time + timedelta(hours=int(step)) - coordinates[date][i]["t"].append(stamp.isoformat() + "Z") - break - break - break - - logging.debug("Coordinates created: %s", coordinates) # noqa: E501 - - end = time.time() - delta = end - start - logging.debug("Coords creation: %s", end) # noqa: E501 - logging.debug("Coords creation: %s", delta) # noqa: E501 - - start = time.time() - logging.debug("Coverage creation: %s", start) # noqa: E501 - - logging.debug("The points found were: %s", points) # noqa: E501 - logging.debug("The fields retrieved were: %s", fields) # noqa: E501 - logging.debug("The range_dict created was: %s", range_dict) # noqa: E501 - - for i, point in enumerate(range(points)): - for date in fields["dates"]: - for level in fields["levels"]: - for num in fields["number"]: - val_dict = {} - for para in fields["param"]: - val_dict[para] = [] - for step in fields["step"]: - key = (date, level, num, para, step) - try: - val_dict[para].append(range_dict[key][i]) - except IndexError: - logging.debug( - f"Index {i} out of range for key {key} in range_dict. " - f"Available keys: {list(range_dict.keys())}" - ) - raise IndexError( - f"Key {key} not found in range_dict. " - f"Please ensure all axes are compressed in config" - ) - mm = mars_metadata.copy() - mm["number"] = num - mm["Forecast date"] = date - mm["levelist"] = level - coordinates[date][i]["levelist"] = [level] - del mm["step"] - self.add_coverage(mm, coordinates[date][i], val_dict) - - end = time.time() - delta = end - start - logging.debug("Coverage creation: %s", end) # noqa: E501 - logging.debug("Coverage creation: %s", delta) # noqa: E501 - - return self.covjson + return self.from_polytope(result, date_key="hdate") diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 25e224c..938c8a1 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -163,6 +163,115 @@ def test_standard_forecast_multiple_coverages(self): assert cov["mars:metadata"]["type"] == "fc" assert cov["mars:metadata"]["model"] == "lisflood" + def test_multiple_steps(self): + # 1 date, 1 param (167 = 2t), 3 steps (0, 6, 12), 1 point → 1 coverage with 3 t-values + 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, 12)), + node("stream", ("oper",)), + node("type", ("fc",)), + make_point(48.0, 11.0, [264.9, 263.8, 262.1]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"]["t"]["values"] == [ + "2025-01-01T00:00:00Z", + "2025-01-01T06:00:00Z", + "2025-01-01T12:00:00Z", + ] + assert cov["ranges"]["2t"]["values"] == [264.9, 263.8, 262.1] + assert "step" not in cov["mars:metadata"] + + 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"]["t"]["values"] == ["2025-01-01T00:00:00Z"] + assert cov["ranges"]["2t"]["values"] == [264.9] + assert cov["ranges"]["2d"]["values"] == [250.1] + + def test_multiple_points_multiple_steps(self): + # 2 dates × 2 steps × 2 points → 4 coverages (2 points × 2 dates), each with 2 t-values + tree = chain(TensorIndexTree(), node("class", ("od",))) + cls = tip(tree) + + for date_val, point_vals in [ + (np.datetime64("2025-01-01T00:00:00"), [[10.0, 20.0], [30.0, 40.0]]), + (np.datetime64("2025-01-02T00:00:00"), [[50.0, 60.0], [70.0, 80.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, point_vals[0])) + fc.add_child(make_point(49.0, 12.0, point_vals[1])) + cls.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 4 + + # point 1 (48.0, 11.0), date 2025-01-01 + cov0 = covjson["coverages"][0] + assert cov0["domain"]["axes"]["latitude"]["values"] == [48.0] + assert cov0["domain"]["axes"]["longitude"]["values"] == [11.0] + assert cov0["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"] + assert cov0["ranges"]["2t"]["values"] == [10.0, 20.0] + assert cov0["mars:metadata"]["Forecast date"] == "2025-01-01T00:00:00Z" + + # point 1, date 2025-01-02 + cov1 = covjson["coverages"][1] + assert cov1["domain"]["axes"]["latitude"]["values"] == [48.0] + assert cov1["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z", "2025-01-02T06:00:00Z"] + assert cov1["ranges"]["2t"]["values"] == [50.0, 60.0] + + # point 2 (49.0, 12.0), date 2025-01-01 + cov2 = covjson["coverages"][2] + assert cov2["domain"]["axes"]["latitude"]["values"] == [49.0] + assert cov2["domain"]["axes"]["longitude"]["values"] == [12.0] + assert cov2["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"] + assert cov2["ranges"]["2t"]["values"] == [30.0, 40.0] + + # point 2, date 2025-01-02 + cov3 = covjson["coverages"][3] + assert cov3["domain"]["axes"]["latitude"]["values"] == [49.0] + assert cov3["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z", "2025-01-02T06:00:00Z"] + assert cov3["ranges"]["2t"]["values"] == [70.0, 80.0] + class TestTimeseriesFromPolytopeReforecast: def test_single_point(self): From df5fbea13d0c1ec8449def42793a96534ce8fc06 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:29:58 +0200 Subject: [PATCH 16/22] tidy: simplify tests --- .../test_encoder_time_series_from_polytope.py | 378 ++++++------------ 1 file changed, 125 insertions(+), 253 deletions(-) diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 938c8a1..4d8fc51 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -45,7 +45,6 @@ def hdate_branch(hdate, lat, lon, result): class TestTimeseriesFromPolytope: def test_standard_forecast_single_point(self): # od/oper/fc/sfc, 1 point, param 167 (2t), steps 0 and 6 - # axis ordering from a real polytope pprint leaf = make_leaf(11.0, [264.931, 263.831]) tree = chain( @@ -98,9 +97,7 @@ def test_standard_forecast_single_point(self): } def test_standard_forecast_multiple_coverages(self): - # ce/efas/fc/sfc flood forecast: class=ce, stream=efas, type=fc, param=240023 (dis06) - # 2 dates (time 00:00 and 12:00 pre-merged), 2 steps (6, 30), 2 points - # → 4 coverages (2 dates × 2 points), each with 2 t-values from steps + # ce/efas/fc/sfc flood forecast: 2 dates × 2 steps × 2 points → 4 coverages tree = chain(TensorIndexTree(), node("class", ("ce",))) cls = tip(tree) @@ -127,70 +124,35 @@ def test_standard_forecast_multiple_coverages(self): covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) - assert len(covjson["coverages"]) == 4 - - # point 1, date=2026-01-01T00:00 - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] - assert cov0["domain"]["axes"]["longitude"]["values"] == [6.5] - assert cov0["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z", "2026-01-02T06:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [12.5, 19.3] - - # point 1, date=2026-01-01T12:00 - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [51.5] - assert cov1["domain"]["axes"]["longitude"]["values"] == [6.5] - assert cov1["domain"]["axes"]["t"]["values"] == ["2026-01-01T18:00:00Z", "2026-01-02T18:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [15.8, 22.6] - - # point 2, date=2026-01-01T00:00 - cov2 = covjson["coverages"][2] - assert cov2["domain"]["axes"]["latitude"]["values"] == [52.0] - assert cov2["domain"]["axes"]["longitude"]["values"] == [7.0] - assert cov2["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z", "2026-01-02T06:00:00Z"] - assert cov2["ranges"]["dis06"]["values"] == [8.7, 14.1] - - # point 2, date=2026-01-01T12:00 - cov3 = covjson["coverages"][3] - assert cov3["domain"]["axes"]["latitude"]["values"] == [52.0] - assert cov3["domain"]["axes"]["longitude"]["values"] == [7.0] - assert cov3["domain"]["axes"]["t"]["values"] == ["2026-01-01T18:00:00Z", "2026-01-02T18:00:00Z"] - assert cov3["ranges"]["dis06"]["values"] == [10.2, 16.9] - - for cov in covjson["coverages"]: - assert cov["mars:metadata"]["class"] == "ce" - assert cov["mars:metadata"]["stream"] == "efas" - assert cov["mars:metadata"]["type"] == "fc" - assert cov["mars:metadata"]["model"] == "lisflood" - - def test_multiple_steps(self): - # 1 date, 1 param (167 = 2t), 3 steps (0, 6, 12), 1 point → 1 coverage with 3 t-values - 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, 12)), - node("stream", ("oper",)), - node("type", ("fc",)), - make_point(48.0, 11.0, [264.9, 263.8, 262.1]), - ) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) - - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] + shared_metadata = { + "class": "ce", + "domain": "g", + "expver": "0001", + "levtype": "sfc", + "model": "lisflood", + "origin": "ecmf", + "stream": "efas", + "type": "fc", + "number": 0, + "levelist": 0, + } - assert cov["domain"]["axes"]["t"]["values"] == [ - "2025-01-01T00:00:00Z", - "2025-01-01T06:00:00Z", - "2025-01-01T12:00:00Z", + 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 cov["ranges"]["2t"]["values"] == [264.9, 263.8, 262.1] - assert "step" not in cov["mars:metadata"] + 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 @@ -213,64 +175,27 @@ def test_multiple_params(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - assert cov["ranges"]["2t"]["values"] == [264.9] - assert cov["ranges"]["2d"]["values"] == [250.1] - - def test_multiple_points_multiple_steps(self): - # 2 dates × 2 steps × 2 points → 4 coverages (2 points × 2 dates), each with 2 t-values - tree = chain(TensorIndexTree(), node("class", ("od",))) - cls = tip(tree) - - for date_val, point_vals in [ - (np.datetime64("2025-01-01T00:00:00"), [[10.0, 20.0], [30.0, 40.0]]), - (np.datetime64("2025-01-02T00:00:00"), [[50.0, 60.0], [70.0, 80.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, point_vals[0])) - fc.add_child(make_point(49.0, 12.0, point_vals[1])) - cls.add_child(branch) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) - - assert len(covjson["coverages"]) == 4 - - # point 1 (48.0, 11.0), date 2025-01-01 - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["latitude"]["values"] == [48.0] - assert cov0["domain"]["axes"]["longitude"]["values"] == [11.0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"] - assert cov0["ranges"]["2t"]["values"] == [10.0, 20.0] - assert cov0["mars:metadata"]["Forecast date"] == "2025-01-01T00:00:00Z" - - # point 1, date 2025-01-02 - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [48.0] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z", "2025-01-02T06:00:00Z"] - assert cov1["ranges"]["2t"]["values"] == [50.0, 60.0] - - # point 2 (49.0, 12.0), date 2025-01-01 - cov2 = covjson["coverages"][2] - assert cov2["domain"]["axes"]["latitude"]["values"] == [49.0] - assert cov2["domain"]["axes"]["longitude"]["values"] == [12.0] - assert cov2["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"] - assert cov2["ranges"]["2t"]["values"] == [30.0, 40.0] - - # point 2, date 2025-01-02 - cov3 = covjson["coverages"][3] - assert cov3["domain"]["axes"]["latitude"]["values"] == [49.0] - assert cov3["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z", "2025-01-02T06:00:00Z"] - assert cov3["ranges"]["2t"]["values"] == [70.0, 80.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: @@ -309,7 +234,7 @@ def test_single_point(self): 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), 1 point → 2 coverages (one per hdate) + # 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])) @@ -317,20 +242,18 @@ def test_multiple_times(self): covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [42.17] - assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [55.30] - assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} + 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 × 1 time (pre-merged), 1 point → 2 coverages (one per hdate) + # 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])) @@ -338,53 +261,18 @@ def test_multiple_hdates(self): covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [42.17] - assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [55.30] - assert cov1["mars:metadata"] == {"Forecast date": "2025-07-15T06:00:00Z", **EXPECTED_HDATE_METADATA} - - def test_multiple_times_and_hdates(self): - # 2 hdates × 2 times (pre-merged into 4 hdate values), 1 point → 4 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])) - date.add_child(hdate_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [61.44])) - date.add_child(hdate_branch(np.datetime64("2025-07-15T12:00:00"), 51.5, 6.5, [73.82])) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - assert len(covjson["coverages"]) == 4 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [42.17] - assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [55.30] - assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} - - cov2 = covjson["coverages"][2] - assert cov2["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z"] - assert cov2["ranges"]["dis06"]["values"] == [61.44] - assert cov2["mars:metadata"] == {"Forecast date": "2025-07-15T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov3 = covjson["coverages"][3] - assert cov3["domain"]["axes"]["t"]["values"] == ["2025-07-15T18:00:00Z"] - assert cov3["ranges"]["dis06"]["values"] == [73.82] - assert cov3["mars:metadata"] == {"Forecast date": "2025-07-15T12:00:00Z", **EXPECTED_HDATE_METADATA} + 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 (pre-merged with time), 2 points → 2 coverages (one per point) + # 1 hdate, 2 points → 2 coverages (one per point) tree = chain( TensorIndexTree(), node("class", ("ce",)), @@ -398,25 +286,23 @@ def test_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] - assert cov0["domain"]["axes"]["longitude"]["values"] == [6.5] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [42.17] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [52.0] - assert cov1["domain"]["axes"]["longitude"]["values"] == [7.0] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [38.91] - - assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + 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 (= 1 hdate × 2 times pre-merged), 2 points → 4 coverages (point × hdate) + # 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) @@ -435,34 +321,21 @@ def test_two_points_two_times(self): covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 4 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["latitude"]["values"] == [51.5] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [42.17] - assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [51.5] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [55.30] - assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} - - cov2 = covjson["coverages"][2] - assert cov2["domain"]["axes"]["latitude"]["values"] == [52.0] - assert cov2["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov2["ranges"]["dis06"]["values"] == [38.91] - assert cov2["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov3 = covjson["coverages"][3] - assert cov3["domain"]["axes"]["latitude"]["values"] == [52.0] - assert cov3["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z"] - assert cov3["ranges"]["dis06"]["values"] == [49.62] - assert cov3["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} + 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.""" + """Single hdate, two steps (6h, 12h), single point → 1 coverage with 2 t-values.""" suffix = [ ("domain", ("g",)), ("expver", ("4321",)), @@ -489,10 +362,12 @@ def test_multiple_steps(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == [ - "2025-07-14T12:00:00Z", - "2025-07-14T18:00:00Z", - ] + 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} @@ -524,9 +399,16 @@ def test_multiple_params(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov["ranges"]["dis06"]["values"] == [42.17] - assert cov["ranges"]["rowe"]["values"] == [99.5] + 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): @@ -560,24 +442,14 @@ def test_multiple_hdates_and_steps(self): covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 4 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"] - assert cov0["ranges"]["dis06"]["values"] == [10.0, 20.0] - assert cov0["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-14T18:00:00Z", "2025-07-15T00:00:00Z"] - assert cov1["ranges"]["dis06"]["values"] == [30.0, 40.0] - assert cov1["mars:metadata"] == {"Forecast date": "2025-07-14T12:00:00Z", **EXPECTED_HDATE_METADATA} - - cov2 = covjson["coverages"][2] - assert cov2["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z", "2025-07-15T18:00:00Z"] - assert cov2["ranges"]["dis06"]["values"] == [50.0, 60.0] - assert cov2["mars:metadata"] == {"Forecast date": "2025-07-15T06:00:00Z", **EXPECTED_HDATE_METADATA} - - cov3 = covjson["coverages"][3] - assert cov3["domain"]["axes"]["t"]["values"] == ["2025-07-15T18:00:00Z", "2025-07-16T00:00:00Z"] - assert cov3["ranges"]["dis06"]["values"] == [70.0, 80.0] - assert cov3["mars:metadata"] == {"Forecast date": "2025-07-15T12:00:00Z", **EXPECTED_HDATE_METADATA} + 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} From a724b3a8247923e2722711d83000c679c30297f1 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:30:18 +0200 Subject: [PATCH 17/22] test: clean up added tests a bit --- ...test_encoder_bounding_box_from_polytope.py | 222 ++++++------ tests/test_encoder_circle_from_polytope.py | 147 ++++---- tests/test_encoder_frame_from_polytope.py | 132 ++++--- tests/test_encoder_grid_from_polytope.py | 276 +++++++------- tests/test_encoder_path_from_polytope.py | 159 ++++---- tests/test_encoder_position_from_polytope.py | 271 ++++++++------ tests/test_encoder_shapefile_from_polytope.py | 132 ++++--- ..._encoder_vertical_profile_from_polytope.py | 339 ++++++++++-------- tests/test_encoder_wkt_from_polytope.py | 130 ++++--- 9 files changed, 1020 insertions(+), 788 deletions(-) diff --git a/tests/test_encoder_bounding_box_from_polytope.py b/tests/test_encoder_bounding_box_from_polytope.py index 4ed18df..02657c1 100644 --- a/tests/test_encoder_bounding_box_from_polytope.py +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -4,6 +4,23 @@ 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): @@ -25,42 +42,38 @@ def test_single_date_single_step_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - # Domain - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["latitude", "longitude", "levelist"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - - # Ranges - assert "2t" in cov["ranges"] - r = cov["ranges"]["2t"] - assert r["type"] == "NdArray" - assert r["dataType"] == "float" - assert r["shape"] == [2] - assert r["axisNames"] == ["2t"] - assert r["values"] == [264.9, 265.1] - - # Metadata - mm = cov["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["domain"] == "g" - assert mm["expver"] == "0001" - assert mm["levtype"] == "sfc" - assert mm["stream"] == "oper" - assert mm["type"] == "fc" - assert mm["number"] == 0 - assert mm["step"] == 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) @@ -85,35 +98,30 @@ def test_two_dates_two_steps_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree) - # 2 dates × 2 steps = 4 coverages - assert len(covjson["coverages"]) == 4 - - # Collect coverages keyed by (date, step) - by_key = {} - for cov in covjson["coverages"]: - mm = cov["mars:metadata"] - by_key[(mm["Forecast date"], mm["step"])] = cov - - # Date 1, step 0 - cov = by_key[("2025-01-01T00:00:00Z", 0)] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - assert cov["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] - - # Date 1, step 6 - cov = by_key[("2025-01-01T00:00:00Z", 6)] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - assert cov["ranges"]["2t"]["values"] == [270.1, 271.3] - - # Date 2, step 0 - cov = by_key[("2025-01-02T00:00:00Z", 0)] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z"] - assert cov["ranges"]["2t"]["values"] == [266.0, 267.0] - - # Date 2, step 6 - cov = by_key[("2025-01-02T00:00:00Z", 6)] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-02T00:00:00Z"] - assert cov["ranges"]["2t"]["values"] == [272.0, 273.0] + 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: @@ -137,23 +145,29 @@ def test_reforecast_single_hdate_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope_reforecast(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["latitude", "longitude", "levelist"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - mm = cov["mars:metadata"] - assert mm["Forecast date"] == "2025-07-14T06:00:00Z" - - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] + 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( @@ -184,17 +198,22 @@ def test_reforecast_two_hdates_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] - assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] + 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( @@ -216,18 +235,19 @@ def test_reforecast_single_hdate_two_steps_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - by_step = {} - for cov in covjson["coverages"]: - by_step[cov["mars:metadata"]["step"]] = cov - - cov0 = by_step[0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] - assert cov0["mars:metadata"]["Forecast date"] == "2025-07-14T06:00:00Z" - - cov6 = by_step[6] - assert cov6["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov6["ranges"]["2t"]["values"] == [270.1, 271.3] - assert cov6["mars:metadata"]["Forecast date"] == "2025-07-14T06:00:00Z" + 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 index 194e403..033b171 100644 --- a/tests/test_encoder_circle_from_polytope.py +++ b/tests/test_encoder_circle_from_polytope.py @@ -4,6 +4,28 @@ 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): @@ -26,40 +48,35 @@ def test_single_date_single_step_three_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - # Domain - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["latitude", "longitude", "levelist"] - assert composite["values"] == [ - [48.0, 11.0, 0], - [49.0, 11.5, 0], - [50.0, 12.0, 0], - ] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - - # Ranges - r = cov["ranges"]["2t"] - assert r["type"] == "NdArray" - assert r["dataType"] == "float" - assert r["shape"] == [3] - assert r["axisNames"] == ["2t"] - assert r["values"] == [264.9, 265.5, 266.1] - - # Metadata - mm = cov["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["stream"] == "oper" - assert mm["type"] == "fc" - assert mm["number"] == 0 - assert mm["step"] == 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: @@ -84,32 +101,25 @@ def test_reforecast_single_hdate_three_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope_reforecast(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["latitude", "longitude", "levelist"] - assert composite["values"] == [ - [48.0, 11.0, 0], - [49.0, 11.5, 0], - [50.0, 12.0, 0], - ] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - mm = cov["mars:metadata"] - assert mm["Forecast date"] == "2025-07-14T06:00:00Z" + 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], + } + } - r = cov["ranges"]["2t"] - assert r["type"] == "NdArray" - assert r["dataType"] == "float" - assert r["shape"] == [3] - assert r["axisNames"] == ["2t"] - assert r["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( @@ -141,22 +151,15 @@ def test_reforecast_two_hdates_three_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov0["domain"]["axes"]["composite"]["values"] == [ - [48.0, 11.0, 0], - [49.0, 11.5, 0], - [50.0, 12.0, 0], - ] - assert cov0["ranges"]["2t"]["values"] == [264.9, 265.5, 266.1] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] - assert cov1["domain"]["axes"]["composite"]["values"] == [ - [48.0, 11.0, 0], - [49.0, 11.5, 0], - [50.0, 12.0, 0], + 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 cov1["ranges"]["2t"]["values"] == [270.0, 271.0, 272.0] + 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 index e9b04e7..1e97bf0 100644 --- a/tests/test_encoder_frame_from_polytope.py +++ b/tests/test_encoder_frame_from_polytope.py @@ -4,6 +4,24 @@ 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): @@ -25,36 +43,35 @@ def test_single_date_single_step_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - # Domain — Frame uses ["x", "y", "z"] coordinates - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["x", "y", "z"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - - # Ranges - r = cov["ranges"]["2t"] - assert r["type"] == "NdArray" - assert r["dataType"] == "float" - assert r["shape"] == [2] - assert r["axisNames"] == ["2t"] - assert r["values"] == [264.9, 265.1] - - # Metadata - mm = cov["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["stream"] == "oper" - assert mm["type"] == "fc" - assert mm["number"] == 0 - assert mm["step"] == 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: @@ -78,23 +95,28 @@ def test_reforecast_single_hdate_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope_reforecast(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["x", "y", "z"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] - - mm = cov["mars:metadata"] - assert mm["Forecast date"] == "2025-07-14T06:00:00Z" + 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( @@ -125,14 +147,18 @@ def test_reforecast_two_hdates_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] - assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] + 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 index f9608cc..29e5d00 100644 --- a/tests/test_encoder_grid_from_polytope.py +++ b/tests/test_encoder_grid_from_polytope.py @@ -4,18 +4,47 @@ 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 _build_grid_tree(self, grid_points, param="167", step=0): - """Build a grid tree. - - grid_points: list of (lat, lon, result_value) triples. - Each is a separate lat→lon subtree under the common metadata chain. - """ - step_tuple = step if isinstance(step, tuple) else (step,) - + 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",)), @@ -23,113 +52,101 @@ def _build_grid_tree(self, grid_points, param="167", step=0): node("domain", ("g",)), node("expver", ("0001",)), node("levtype", ("sfc",)), - node("param", (param,)), - node("step", step_tuple), + node("param", ("167",)), + node("step", (0,)), node("stream", ("oper",)), node("type", ("an",)), ) parent = tip(tree) for lat, lon, vals in grid_points: - if not isinstance(vals, list): - vals = [vals] parent.add_child(make_point(lat, lon, vals)) - return tree - - 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 = self._build_grid_tree(grid_points) - encoder = Covjsonkit().encode("CoverageCollection", "Grid") - covjson = encoder.from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree) assert covjson["type"] == "CoverageCollection" assert covjson["domainType"] == "Grid" - assert len(covjson["coverages"]) == 1 + # 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["type"] == "Coverage" - - # Domain axes - axes = cov["domain"]["axes"] - assert axes["t"]["values"] == [0] - assert axes["levelist"]["values"] == [0] - assert axes["latitude"]["values"] == [48.0, 50.0] - assert axes["longitude"]["values"] == [11.0, 12.0] - - # Range - assert "2t" in cov["ranges"] - rng = cov["ranges"]["2t"] - assert rng["type"] == "NdArray" - assert rng["dataType"] == "float" - assert rng["axisNames"] == ["t", "levelist", "latitude", "longitude"] - assert rng["shape"] == [1, 1, 2, 2] - assert rng["values"] == [264.9, 265.1, 266.3, 267.5] + + 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): - """1×1 grid: single point.""" - grid_points = [(48.0, 11.0, [264.9])] - tree = self._build_grid_tree(grid_points) - encoder = Covjsonkit().encode("CoverageCollection", "Grid") - covjson = encoder.from_polytope(tree) + """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"]["latitude"]["values"] == [48.0] - assert cov["domain"]["axes"]["longitude"]["values"] == [11.0] - assert cov["ranges"]["2t"]["shape"] == [1, 1, 1, 1] - assert cov["ranges"]["2t"]["values"] == [264.9] - - def test_metadata(self): - """mars:metadata should be populated correctly.""" - grid_points = [(48.0, 11.0, [264.9])] - tree = self._build_grid_tree(grid_points) - encoder = Covjsonkit().encode("CoverageCollection", "Grid") - covjson = encoder.from_polytope(tree) - - mm = covjson["coverages"][0]["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["number"] == 0 - - def test_referencing(self): - """Check the CRS referencing block.""" - grid_points = [(48.0, 11.0, [264.9])] - tree = self._build_grid_tree(grid_points) - encoder = Covjsonkit().encode("CoverageCollection", "Grid") - covjson = encoder.from_polytope(tree) - - ref = covjson["referencing"][0] - assert ref["coordinates"] == ["latitude", "longitude", "levelist"] - assert ref["system"]["type"] == "GeographicCRS" - - def test_parameters_block(self): - """Top-level parameters dict should have param 167 = '2t'.""" - grid_points = [(48.0, 11.0, [264.9])] - tree = self._build_grid_tree(grid_points) - encoder = Covjsonkit().encode("CoverageCollection", "Grid") - covjson = encoder.from_polytope(tree) - assert "2t" in covjson["parameters"] - p = covjson["parameters"]["2t"] - assert p["type"] == "Parameter" + 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 2×2 grid → 1 Grid coverage.""" - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + 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",)), @@ -138,11 +155,26 @@ def test_reforecast_single_hdate_2x2_grid(self): 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(48.0, 12.0, [265.1])) - fc.add_child(make_point(50.0, 11.0, [266.3])) - fc.add_child(make_point(50.0, 12.0, [267.5])) + 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" @@ -150,42 +182,42 @@ def test_reforecast_single_hdate_2x2_grid(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - axes = cov["domain"]["axes"] - assert len(axes["latitude"]["values"]) == 2 - assert len(axes["longitude"]["values"]) == 2 - assert axes["t"]["values"] == [0] - rng = cov["ranges"]["2t"] - assert rng["shape"] == [1, 1, 2, 2] - assert rng["values"] == [264.9, 265.1, 266.3, 267.5] + 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")]: - 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(48.0, 12.0, [265.1])) - fc.add_child(make_point(50.0, 11.0, [266.3])) - fc.add_child(make_point(50.0, 12.0, [267.5])) - date_node.add_child(branch) + date_node.add_child(self._build_reforecast_branch(hdate_val, grid_points)) covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 + 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 index 3de8fd6..7de459a 100644 --- a/tests/test_encoder_path_from_polytope.py +++ b/tests/test_encoder_path_from_polytope.py @@ -44,80 +44,94 @@ def test_two_points_single_step(self): (49.0, 12.0, [265.1]), ] tree = self._build_path_tree(points) - encoder = Covjsonkit().encode("CoverageCollection", "Path") - covjson = encoder.from_polytope(tree) + 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["type"] == "Coverage" - - # Domain: composite tuples [step, lat, lon, level] - comp = cov["domain"]["axes"]["composite"] - assert comp["dataType"] == "tuple" - assert comp["coordinates"] == ["t", "x", "y", "z"] - assert len(comp["values"]) == 2 - assert comp["values"][0] == [0, 48.0, 11.0, 0] - assert comp["values"][1] == [0, 49.0, 12.0, 0] - - # Range - assert "2t" in cov["ranges"] - rng = cov["ranges"]["2t"] - assert rng["type"] == "NdArray" - assert rng["dataType"] == "float" - assert rng["shape"] == [2] - assert rng["values"] == [264.9, 265.1] - - # Metadata: levelist should be removed - mm = cov["mars:metadata"] - assert "levelist" not in mm - assert mm["number"] == 0 - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - - def test_three_points_along_path(self): - """3 points along a path → composite has 3 tuples.""" - points = [ - (48.0, 11.0, [264.9]), - (49.0, 12.0, [265.1]), - (50.0, 13.0, [266.3]), - ] - tree = self._build_path_tree(points) - encoder = Covjsonkit().encode("CoverageCollection", "Path") - covjson = encoder.from_polytope(tree) - - cov = covjson["coverages"][0] - assert len(cov["domain"]["axes"]["composite"]["values"]) == 3 - assert cov["ranges"]["2t"]["shape"] == [3] - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1, 266.3] - def test_referencing(self): - """Check the CRS referencing block uses [t, x, y, z].""" - points = [(48.0, 11.0, [264.9])] - tree = self._build_path_tree(points) - encoder = Covjsonkit().encode("CoverageCollection", "Path") - covjson = encoder.from_polytope(tree) - - ref = covjson["referencing"][0] - assert ref["coordinates"] == ["t", "x", "y", "z"] - assert ref["system"]["type"] == "GeographicCRS" - - def test_parameters_block(self): - """Top-level parameters dict should have param 167 = '2t'.""" - points = [(48.0, 11.0, [264.9])] - tree = self._build_path_tree(points) - encoder = Covjsonkit().encode("CoverageCollection", "Path") - covjson = encoder.from_polytope(tree) + 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"] - p = covjson["parameters"]["2t"] - assert p["type"] == "Parameter" + 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( @@ -143,16 +157,13 @@ def test_reforecast_single_hdate_two_points(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - comp = cov["domain"]["axes"]["composite"] - assert comp["dataType"] == "tuple" - assert comp["coordinates"] == ["t", "x", "y", "z"] - assert comp["values"] == [[0, 48.0, 11.0, 0], [0, 50.0, 12.0, 0]] - - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] - mm = cov["mars:metadata"] - assert "Forecast date" in mm - assert "2025-07-14" in mm["Forecast date"] + 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.""" @@ -181,4 +192,12 @@ def test_reforecast_two_hdates_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Path").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 + 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 index f7a9843..90ab4a1 100644 --- a/tests/test_encoder_position_from_polytope.py +++ b/tests/test_encoder_position_from_polytope.py @@ -35,39 +35,50 @@ 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) - encoder = Covjsonkit().encode("CoverageCollection", "Position") - covjson = encoder.from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree) assert covjson["type"] == "CoverageCollection" assert covjson["domainType"] == "PointSeries" - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - # Domain axes - axes = cov["domain"]["axes"] - assert axes["latitude"]["values"] == [48.0] - assert axes["longitude"]["values"] == [11.0] - assert axes["levelist"]["values"] == [0] - assert axes["t"]["values"] == [ - "2025-01-01T00:00:00Z", - "2025-01-01T06:00:00Z", - ] + # 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" - # Range - assert "2t" in cov["ranges"] - rng = cov["ranges"]["2t"] - assert rng["type"] == "NdArray" - assert rng["dataType"] == "float" - assert rng["shape"] == [2] - assert rng["values"] == [264.9, 263.8] + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] - # Metadata: "step" key should be deleted by from_polytope - mm = cov["mars:metadata"] - assert "step" not in mm - assert mm["number"] == 0 - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" + 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).""" @@ -76,54 +87,58 @@ def test_two_points_two_steps(self): (50.0, 13.0, [265.1, 264.2]), ] tree = self._build_position_tree(points) - encoder = Covjsonkit().encode("CoverageCollection", "Position") - covjson = encoder.from_polytope(tree) - - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["latitude"]["values"] == [48.0] - assert cov0["domain"]["axes"]["longitude"]["values"] == [11.0] - assert cov0["ranges"]["2t"]["values"] == [264.9, 263.8] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [50.0] - assert cov1["domain"]["axes"]["longitude"]["values"] == [13.0] - assert cov1["ranges"]["2t"]["values"] == [265.1, 264.2] + 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): - """Single step → t has just 1 value.""" + """Edge case: 1 step → shape [1], single t-value.""" points = [(48.0, 11.0, [264.9])] tree = self._build_position_tree(points, steps=(0,)) - encoder = Covjsonkit().encode("CoverageCollection", "Position") - covjson = encoder.from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree) + assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - assert cov["ranges"]["2t"]["values"] == [264.9] - assert cov["ranges"]["2t"]["shape"] == [1] - def test_referencing(self): - """Check the CRS referencing block.""" - points = [(48.0, 11.0, [264.9])] - tree = self._build_position_tree(points, steps=(0,)) - encoder = Covjsonkit().encode("CoverageCollection", "Position") - covjson = encoder.from_polytope(tree) - - ref = covjson["referencing"][0] - assert ref["coordinates"] == ["latitude", "longitude", "levelist"] - assert ref["system"]["type"] == "GeographicCRS" - - def test_parameters_block(self): - """Top-level parameters dict should have param 167 = '2t'.""" - points = [(48.0, 11.0, [264.9])] - tree = self._build_position_tree(points, steps=(0,)) - encoder = Covjsonkit().encode("CoverageCollection", "Position") - covjson = encoder.from_polytope(tree) - - assert "2t" in covjson["parameters"] - p = covjson["parameters"]["2t"] - assert p["type"] == "Parameter" + 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: @@ -150,22 +165,32 @@ def test_reforecast_single_hdate_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Position").from_polytope_reforecast(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "PointSeries" - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - axes0 = cov0["domain"]["axes"] - assert axes0["latitude"]["values"] == [48.0] - assert axes0["longitude"]["values"] == [11.0] - # t = hdate(06:00) + step(0) = 06:00 - assert axes0["t"]["values"] == ["2025-07-14T06:00:00Z"] - - cov1 = covjson["coverages"][1] - axes1 = cov1["domain"]["axes"] - assert axes1["latitude"]["values"] == [50.0] - assert axes1["longitude"]["values"] == [12.0] - assert axes1["t"]["values"] == ["2025-07-14T06:00:00Z"] + 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.""" @@ -176,39 +201,51 @@ def test_reforecast_two_hdates_two_points(self): ) date_node = tip(tree) - # hdate 1 - hdate1 = chain( - 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",)), - ) - fc1 = tip(hdate1) - fc1.add_child(make_point(48.0, 11.0, [264.9])) - fc1.add_child(make_point(50.0, 12.0, [265.1])) - - # hdate 2 - hdate2 = chain( - node("hdate", (np.datetime64("2025-07-15T06:00:00"),)), - node("domain", ("g",)), - node("expver", ("4321",)), - node("levtype", ("sfc",)), - node("param", ("167",)), - node("step", (0,)), - node("stream", ("efcl",)), - node("type", ("sfo",)), - ) - fc2 = tip(hdate2) - fc2.add_child(make_point(48.0, 11.0, [266.0])) - fc2.add_child(make_point(50.0, 12.0, [267.0])) - - date_node.add_child(hdate1) - date_node.add_child(hdate2) + 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) - assert len(covjson["coverages"]) == 4 + 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 index e5f1e42..51183d1 100644 --- a/tests/test_encoder_shapefile_from_polytope.py +++ b/tests/test_encoder_shapefile_from_polytope.py @@ -4,6 +4,18 @@ 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): @@ -25,36 +37,39 @@ def test_single_date_single_step_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - # Domain — Shapefile uses ["x", "y", "z"] coordinates - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["x", "y", "z"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - - # Ranges - r = cov["ranges"]["2t"] - assert r["type"] == "NdArray" - assert r["dataType"] == "float" - assert r["shape"] == [2] - assert r["axisNames"] == ["2t"] - assert r["values"] == [264.9, 265.1] - - # Metadata - mm = cov["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["stream"] == "oper" - assert mm["type"] == "fc" - assert mm["number"] == 0 - assert mm["step"] == 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: @@ -78,23 +93,29 @@ def test_reforecast_single_hdate_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope_reforecast(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["x", "y", "z"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] - - mm = cov["mars:metadata"] - assert mm["Forecast date"] == "2025-07-14T06:00:00Z" + 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( @@ -125,14 +146,19 @@ def test_reforecast_two_hdates_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] - assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] + 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_vertical_profile_from_polytope.py b/tests/test_encoder_vertical_profile_from_polytope.py index 94814f4..fde5c4e 100644 --- a/tests/test_encoder_vertical_profile_from_polytope.py +++ b/tests/test_encoder_vertical_profile_from_polytope.py @@ -40,38 +40,53 @@ def _build_vp_tree(self, param="130", levels_values=None, step=0, lat=48.0, lon= def test_single_point_three_levels(self): """1 point, 3 pressure levels, param 130 (t), step 0.""" tree = self._build_vp_tree() - encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") - covjson = encoder.from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree) assert covjson["type"] == "CoverageCollection" assert covjson["domainType"] == "VerticalProfile" - assert len(covjson["coverages"]) == 1 + # 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["type"] == "Coverage" - - # Domain axes - axes = cov["domain"]["axes"] - assert axes["latitude"]["values"] == [48.0] - assert axes["longitude"]["values"] == [11.0] - assert axes["levelist"]["values"] == [1000, 850, 500] - assert axes["t"]["values"] == ["2025-01-01T00:00:00Z"] - - # Range - assert "t" in cov["ranges"] - rng = cov["ranges"]["t"] - assert rng["type"] == "NdArray" - assert rng["dataType"] == "float" - assert rng["axisNames"] == ["levelist"] - assert rng["shape"] == [3] - assert rng["values"] == [290.1, 280.2, 250.3] - - # Metadata - mm = cov["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["number"] == 0 - assert mm["step"] == 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).""" @@ -90,77 +105,112 @@ def test_two_points_two_levels(self): node("levelist", levels), ) lev_node = tip(tree) - # Point 1: lat=48, lon=11 lev_node.add_child(make_point(48.0, 11.0, [290.1, 250.3])) - # Point 2: lat=50, lon=13 lev_node.add_child(make_point(50.0, 13.0, [288.5, 248.7])) - encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") - covjson = encoder.from_polytope(tree) - - assert len(covjson["coverages"]) == 2 - - # First coverage = first point - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["latitude"]["values"] == [48.0] - assert cov0["domain"]["axes"]["longitude"]["values"] == [11.0] - assert cov0["domain"]["axes"]["levelist"]["values"] == [1000, 500] - assert cov0["ranges"]["t"]["values"] == [290.1, 250.3] - assert cov0["ranges"]["t"]["shape"] == [2] - - # Second coverage = second point - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["latitude"]["values"] == [50.0] - assert cov1["domain"]["axes"]["longitude"]["values"] == [13.0] - assert cov1["ranges"]["t"]["values"] == [288.5, 248.7] - - def test_single_level(self): - """Edge case: only 1 pressure level.""" - tree = self._build_vp_tree(levels_values={500: 250.3}) - encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") - covjson = encoder.from_polytope(tree) - - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["levelist"]["values"] == [500] - assert cov["ranges"]["t"]["values"] == [250.3] - assert cov["ranges"]["t"]["shape"] == [1] + 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}) - encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") - covjson = encoder.from_polytope(tree) + covjson = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree) + assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T06:00:00Z"] - assert cov["mars:metadata"]["step"] == 6 - - def test_referencing(self): - """Check the CRS referencing block.""" - tree = self._build_vp_tree(levels_values={1000: 290.0}) - encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") - covjson = encoder.from_polytope(tree) - ref = covjson["referencing"][0] - assert ref["coordinates"] == ["latitude", "longitude", "levelist"] - assert ref["system"]["type"] == "GeographicCRS" - - def test_parameters_block(self): - """The top-level parameters dict should contain param 130 = 't'.""" - tree = self._build_vp_tree(levels_values={1000: 290.0}) - encoder = Covjsonkit().encode("CoverageCollection", "VerticalProfile") - covjson = encoder.from_polytope(tree) - - assert "t" in covjson["parameters"] - p = covjson["parameters"]["t"] - assert p["type"] == "Parameter" - assert "Temperature" in p["observedProperty"]["label"]["en"] + 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( @@ -168,13 +218,7 @@ def test_reforecast_single_hdate_three_levels(self): 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", ("pl",)), - node("param", ("130",)), - node("step", (6,)), - node("stream", ("efcl",)), - node("type", ("sfo",)), + *[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]), @@ -186,21 +230,28 @@ def test_reforecast_single_hdate_three_levels(self): assert len(covjson["coverages"]) == 1 cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - axes = cov["domain"]["axes"] - assert axes["levelist"]["values"] == [1000, 850, 500] - assert axes["latitude"]["values"] == [48.0] - assert axes["longitude"]["values"] == [11.0] - # t = hdate(06:00) + step(6h) = 12:00 - assert axes["t"]["values"] == ["2025-07-14T12:00:00Z"] - - assert "t" in cov["ranges"] - rng = cov["ranges"]["t"] - assert rng["type"] == "NdArray" - assert rng["dataType"] == "float" - assert rng["shape"] == [3] - assert rng["values"] == [290.1, 280.2, 250.3] + + 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).""" @@ -211,47 +262,43 @@ def test_reforecast_two_hdates_three_levels(self): ) date_node = tip(tree) - # hdate 1 - hdate1 = chain( - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), - node("domain", ("g",)), - node("expver", ("4321",)), - node("levtype", ("pl",)), - node("param", ("130",)), - node("step", (6,)), - node("stream", ("efcl",)), - node("type", ("sfo",)), - node("levelist", (1000, 850, 500)), - node("latitude", (48.0,)), - make_leaf(11.0, [290.1, 280.2, 250.3]), - ) - - # hdate 2 - hdate2 = chain( - node("hdate", (np.datetime64("2025-07-15T06:00:00"),)), - node("domain", ("g",)), - node("expver", ("4321",)), - node("levtype", ("pl",)), - node("param", ("130",)), - node("step", (6,)), - node("stream", ("efcl",)), - node("type", ("sfo",)), - node("levelist", (1000, 850, 500)), - node("latitude", (48.0,)), - make_leaf(11.0, [291.0, 281.0, 251.0]), - ) - - date_node.add_child(hdate1) - date_node.add_child(hdate2) + 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) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert cov0["ranges"]["t"]["values"] == [290.1, 280.2, 250.3] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T12:00:00Z"] - assert cov1["ranges"]["t"]["values"] == [291.0, 281.0, 251.0] + 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 index d9d78e9..78fb76e 100644 --- a/tests/test_encoder_wkt_from_polytope.py +++ b/tests/test_encoder_wkt_from_polytope.py @@ -4,6 +4,24 @@ 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): @@ -23,39 +41,37 @@ def test_single_date_single_step_two_points(self): fc.add_child(make_point(48.0, 11.0, [264.9])) fc.add_child(make_point(50.0, 12.0, [265.1])) - # Wkt encoder is dispatched via "Polygon" covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - - # Domain — Wkt uses ["x", "y", "z"] coordinates - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["x", "y", "z"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-01-01T00:00:00Z"] - - # Ranges - r = cov["ranges"]["2t"] - assert r["type"] == "NdArray" - assert r["dataType"] == "float" - assert r["shape"] == [2] - assert r["axisNames"] == ["2t"] - assert r["values"] == [264.9, 265.1] - - # Metadata - mm = cov["mars:metadata"] - assert mm["class"] == "od" - assert mm["Forecast date"] == "2025-01-01T00:00:00Z" - assert mm["stream"] == "oper" - assert mm["type"] == "fc" - assert mm["number"] == 0 - assert mm["step"] == 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: @@ -79,23 +95,28 @@ def test_reforecast_single_hdate_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope_reforecast(tree) - assert covjson["type"] == "CoverageCollection" - assert covjson["domainType"] == "MultiPoint" assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["type"] == "Coverage" - composite = cov["domain"]["axes"]["composite"] - assert composite["dataType"] == "tuple" - assert composite["coordinates"] == ["x", "y", "z"] - assert composite["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - - assert cov["ranges"]["2t"]["values"] == [264.9, 265.1] - - mm = cov["mars:metadata"] - assert mm["Forecast date"] == "2025-07-14T06:00:00Z" + 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( @@ -126,14 +147,15 @@ def test_reforecast_two_hdates_two_points(self): covjson = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 2 - - cov0 = covjson["coverages"][0] - assert cov0["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov0["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov0["ranges"]["2t"]["values"] == [264.9, 265.1] - - cov1 = covjson["coverages"][1] - assert cov1["domain"]["axes"]["t"]["values"] == ["2025-07-15T06:00:00Z"] - assert cov1["domain"]["axes"]["composite"]["values"] == [[48.0, 11.0, 0], [50.0, 12.0, 0]] - assert cov1["ranges"]["2t"]["values"] == [266.0, 267.0] + 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} From c440ff3c2a547ef15e70c16772307733bb55baf6 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:28:34 +0200 Subject: [PATCH 18/22] chore: update type hints and docstrings --- covjsonkit/encoder/BoundingBox.py | 7 ++----- covjsonkit/encoder/Circle.py | 7 ++----- covjsonkit/encoder/Frame.py | 7 ++----- covjsonkit/encoder/Grid.py | 7 ++----- covjsonkit/encoder/Path.py | 7 ++----- covjsonkit/encoder/Position.py | 17 ++++++++--------- covjsonkit/encoder/Shapefile.py | 7 ++----- covjsonkit/encoder/TimeSeries.py | 22 ++++++++-------------- covjsonkit/encoder/VerticalProfile.py | 6 ++---- covjsonkit/encoder/Wkt.py | 7 ++----- covjsonkit/encoder/encoder.py | 24 +++++++++++------------- 11 files changed, 43 insertions(+), 75 deletions(-) diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index d834af8..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, date_key="date"): - + 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 = {} @@ -202,9 +202,6 @@ def from_polytope(self, result, date_key="date"): 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 2271480..b74105d 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -114,8 +114,8 @@ def from_xarray(self, dataset): # Return the generated CoverageJSON return self.covjsonå - def from_polytope(self, result, date_key="date"): - + 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 = {} @@ -200,9 +200,6 @@ def from_polytope(self, result, date_key="date"): 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 7f70a83..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, date_key="date"): - + 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 = {} @@ -195,9 +195,6 @@ def from_polytope(self, result, date_key="date"): 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 b9a2fcb..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, date_key="date"): - + 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 = {} @@ -217,9 +217,6 @@ def from_polytope(self, result, date_key="date"): 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 0133aa4..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, date_key="date"): - + 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 = {} @@ -225,9 +225,6 @@ def from_polytope(self, result, date_key="date"): 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 790d627..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, date_key="date"): - """ - 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 = {} @@ -251,9 +253,6 @@ def from_polytope(self, result, date_key="date"): 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 19abab8..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, date_key="date"): - + 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 = {} @@ -195,9 +195,6 @@ def from_polytope(self, result, date_key="date"): 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 9a13924..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, date_key="date"): - """ - 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 = {} @@ -471,11 +473,3 @@ 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 7648df6..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, date_key="date"): + 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 = {} @@ -239,9 +240,6 @@ def from_polytope(self, result, date_key="date"): 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 f5b4e18..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, date_key="date"): - + 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 = {} @@ -204,9 +204,6 @@ def from_polytope(self, result, date_key="date"): 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 9be7000..2968dc6 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -1,6 +1,5 @@ from __future__ import annotations -import typing from abc import ABC, abstractmethod from typing import Any @@ -11,9 +10,6 @@ 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): @@ -158,7 +154,7 @@ def get_json(self): def walk_tree( self, - tree: TensorIndexTree, + tree, fields: dict[str, Any], coords: dict[str, dict[str, list]], mars_metadata: dict[str, Any], @@ -168,7 +164,7 @@ def walk_tree( """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). + (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"]``. @@ -180,7 +176,6 @@ def create_composite_key(date, level, num, para, s): def handle_non_leaf_node(child): 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): @@ -192,9 +187,6 @@ def handle_specific_axes(child): return child.values 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] = {} @@ -557,8 +549,14 @@ def from_xarray(self, dataset): pass @abstractmethod - def from_polytope(self, result, date_key="date"): + def from_polytope(self, result, date_key: str = "date") -> dict: pass - def from_polytope_reforecast(self, result): - raise NotImplementedError(f"{type(self).__name__} does not implement from_polytope_reforecast") + 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") From 691687921fb8afc3e638543bdd87a84455b39b0d Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:48:53 +0200 Subject: [PATCH 19/22] refactor: remove duplication in added tests --- tests/conftest.py | 87 +++++++++++++- ...test_encoder_bounding_box_from_polytope.py | 102 +++++----------- tests/test_encoder_circle_from_polytope.py | 95 ++++----------- tests/test_encoder_frame_from_polytope.py | 104 ++++------------ tests/test_encoder_grid_from_polytope.py | 99 +++++---------- tests/test_encoder_path_from_polytope.py | 113 ++++-------------- tests/test_encoder_position_from_polytope.py | 104 ++++------------ tests/test_encoder_shapefile_from_polytope.py | 110 ++++------------- tests/test_encoder_wkt_from_polytope.py | 104 ++++------------ 9 files changed, 292 insertions(+), 626 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c5bfbc9..79e3744 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,29 @@ 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.""" @@ -18,7 +41,7 @@ def chain(*nodes): def tip(tree): - """Walk to the deepest single-child descendant.""" + """Walk to the deepest single-child descendant (the 'tip' of a linear chain).""" while tree.children: tree = tree.children[0] return tree @@ -36,3 +59,65 @@ def make_point(lat, lon, result): 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/test_encoder_bounding_box_from_polytope.py b/tests/test_encoder_bounding_box_from_polytope.py index 02657c1..0739a54 100644 --- a/tests/test_encoder_bounding_box_from_polytope.py +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -1,9 +1,19 @@ import numpy as np -from conftest import chain, make_point, node, tip +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"], @@ -21,25 +31,12 @@ "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 = 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])) - + tree = forecast_tree(TWO_POINTS) covjson = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree) assert len(covjson["coverages"]) == 1 @@ -126,22 +123,11 @@ def test_two_dates_two_steps_two_points(self): 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",)), + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS), + ] ) - 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) @@ -170,31 +156,12 @@ def test_reforecast_single_hdate_two_points(self): } def test_reforecast_two_hdates_two_points(self): - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), + 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])]), + ] ) - 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) @@ -216,22 +183,15 @@ def test_reforecast_two_hdates_two_points(self): } 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",)), + 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), + ), + ] ) - 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) diff --git a/tests/test_encoder_circle_from_polytope.py b/tests/test_encoder_circle_from_polytope.py index 033b171..1baceb0 100644 --- a/tests/test_encoder_circle_from_polytope.py +++ b/tests/test_encoder_circle_from_polytope.py @@ -1,6 +1,10 @@ import numpy as np -from conftest import chain, make_point, node, tip -from polytope_feature.datacube.tensor_index_tree import TensorIndexTree +from conftest import ( + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) from covjsonkit.api import Covjsonkit @@ -14,38 +18,12 @@ ], } -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, -} +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 = 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])) - + tree = forecast_tree(THREE_POINTS) covjson = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope(tree) assert len(covjson["coverages"]) == 1 @@ -81,23 +59,11 @@ def test_single_date_single_step_three_points(self): 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",)), + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), THREE_POINTS), + ] ) - 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) @@ -119,35 +85,18 @@ def test_reforecast_single_hdate_three_points(self): } } - assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_REFORECAST_METADATA} + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **REFORECAST_METADATA_BASE} def test_reforecast_two_hdates_three_points(self): - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), + 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])], + ), + ] ) - 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) @@ -162,4 +111,4 @@ def test_reforecast_two_hdates_three_points(self): "composite": THREE_POINTS_COMPOSITE, } assert cov["ranges"]["2t"]["values"] == range_vals - assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_REFORECAST_METADATA} + 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 index 1e97bf0..0330dc4 100644 --- a/tests/test_encoder_frame_from_polytope.py +++ b/tests/test_encoder_frame_from_polytope.py @@ -1,46 +1,18 @@ import numpy as np -from conftest import chain, make_point, node, tip -from polytope_feature.datacube.tensor_index_tree import TensorIndexTree +from conftest import ( + COMPOSITE_TWO_POINTS_XYZ, + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) 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])) - + 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 @@ -48,7 +20,7 @@ def test_single_date_single_step_two_points(self): assert cov["domain"]["axes"] == { "t": {"values": ["2025-01-01T00:00:00Z"]}, - "composite": COMPOSITE_TWO_POINTS, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"] == { @@ -76,22 +48,12 @@ def test_single_date_single_step_two_points(self): 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",)), + 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), + ] ) - 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) @@ -100,7 +62,7 @@ def test_reforecast_single_hdate_two_points(self): assert cov["domain"]["axes"] == { "t": {"values": ["2025-07-14T06:00:00Z"]}, - "composite": COMPOSITE_TWO_POINTS, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"] == { @@ -114,36 +76,18 @@ def test_reforecast_single_hdate_two_points(self): } assert cov["mars:metadata"] == { - **REFORECAST_SHARED_METADATA, + **REFORECAST_METADATA_BASE, "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"),)), + 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])]), + ] ) - 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) @@ -155,10 +99,10 @@ def test_reforecast_two_hdates_two_points(self): for cov, (fc_date, vals) in zip(covjson["coverages"], expected): assert cov["domain"]["axes"] == { "t": {"values": [fc_date]}, - "composite": COMPOSITE_TWO_POINTS, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"]["2t"]["values"] == vals assert cov["mars:metadata"] == { - **REFORECAST_SHARED_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 index 29e5d00..146f3ec 100644 --- a/tests/test_encoder_grid_from_polytope.py +++ b/tests/test_encoder_grid_from_polytope.py @@ -1,5 +1,13 @@ import numpy as np -from conftest import chain, make_point, node, tip +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 @@ -21,30 +29,21 @@ } } -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, -} +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): - """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]), - ] + """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",)), @@ -58,7 +57,7 @@ def test_2x2_grid(self): node("type", ("an",)), ) parent = tip(tree) - for lat, lon, vals in grid_points: + for lat, lon, vals in GRID_2X2_POINTS: parent.add_child(make_point(lat, lon, vals)) covjson = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree) @@ -103,7 +102,7 @@ def test_2x2_grid(self): } def test_1x1_grid(self): - """Edge case: single-point grid → shape [1,1,1,1].""" + """Edge case: single-point grid -> shape [1,1,1,1].""" tree = chain( TensorIndexTree(), node("class", ("od",)), @@ -143,36 +142,12 @@ def test_1x1_grid(self): 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), + """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) @@ -186,26 +161,18 @@ def test_reforecast_single_hdate_2x2_grid(self): assert cov["domain"]["axes"] == GRID_2X2_AXES assert cov["ranges"] == GRID_2X2_RANGES assert cov["mars:metadata"] == { - **EXPECTED_REFORECAST_METADATA, + **REFORECAST_METADATA_BASE, "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"),)), + """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), + ] ) - 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) @@ -218,6 +185,6 @@ def test_reforecast_two_hdates_2x2_grid(self): assert cov["domain"]["axes"] == GRID_2X2_AXES assert cov["ranges"] == GRID_2X2_RANGES assert cov["mars:metadata"] == { - **EXPECTED_REFORECAST_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 index 7de459a..7f5e389 100644 --- a/tests/test_encoder_path_from_polytope.py +++ b/tests/test_encoder_path_from_polytope.py @@ -1,49 +1,23 @@ import numpy as np -from conftest import chain, make_point, node, tip -from polytope_feature.datacube.tensor_index_tree import TensorIndexTree +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 _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) + """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" @@ -102,18 +76,6 @@ def test_two_points_single_step(self): 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", @@ -133,23 +95,12 @@ class TestPathFromPolytopeReforecast: } 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",)), + """Single hdate with 2 path points -> 1 Trajectory coverage.""" + tree = reforecast_tree( + [ + reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS), + ] ) - 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" @@ -161,34 +112,18 @@ def test_reforecast_single_hdate_two_points(self): assert cov["domain"]["axes"] == self.EXPECTED_AXES assert cov["ranges"] == self.EXPECTED_RANGES assert cov["mars:metadata"] == { - **self.SHARED_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 = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), + """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), + ] ) - 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) @@ -200,4 +135,4 @@ def test_reforecast_two_hdates_two_points(self): 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} + 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 index 90ab4a1..3d33a11 100644 --- a/tests/test_encoder_position_from_polytope.py +++ b/tests/test_encoder_position_from_polytope.py @@ -1,6 +1,5 @@ import numpy as np -from conftest import chain, make_point, node, tip -from polytope_feature.datacube.tensor_index_tree import TensorIndexTree +from conftest import forecast_tree, reforecast_branch, reforecast_tree from covjsonkit.api import Covjsonkit @@ -8,44 +7,20 @@ 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) + """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 (folded from former test_referencing) + # Referencing ref = covjson["referencing"][0] assert ref["coordinates"] == ["latitude", "longitude", "levelist"] assert ref["system"]["type"] == "GeographicCRS" - # Parameters (folded from former test_parameters_block) + # Parameters assert "2t" in covjson["parameters"] assert covjson["parameters"]["2t"]["type"] == "Parameter" @@ -81,12 +56,11 @@ def test_single_point_two_steps(self): } 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) + """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 = { @@ -116,9 +90,8 @@ def test_two_points_two_steps(self): 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,)) + """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 @@ -145,23 +118,13 @@ 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",)), + """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), + ] ) - 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) @@ -193,32 +156,13 @@ def test_reforecast_single_hdate_two_points(self): 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"),)), + """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])]), + ] ) - 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) diff --git a/tests/test_encoder_shapefile_from_polytope.py b/tests/test_encoder_shapefile_from_polytope.py index 51183d1..611d7f1 100644 --- a/tests/test_encoder_shapefile_from_polytope.py +++ b/tests/test_encoder_shapefile_from_polytope.py @@ -1,40 +1,18 @@ import numpy as np -from conftest import chain, make_point, node, tip -from polytope_feature.datacube.tensor_index_tree import TensorIndexTree +from conftest import ( + COMPOSITE_TWO_POINTS_XYZ, + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) 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])) - + 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 @@ -42,11 +20,7 @@ def test_single_date_single_step_two_points(self): 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]], - }, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"] == { @@ -74,22 +48,12 @@ def test_single_date_single_step_two_points(self): 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",)), + 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), + ] ) - 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) @@ -98,11 +62,7 @@ def test_reforecast_single_hdate_two_points(self): 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]], - }, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"] == { @@ -115,34 +75,16 @@ def test_reforecast_single_hdate_two_points(self): } } - assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_REFORECAST_METADATA} + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **REFORECAST_METADATA_BASE} def test_reforecast_two_hdates_two_points(self): - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), + 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])]), + ] ) - 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) @@ -154,11 +96,7 @@ def test_reforecast_two_hdates_two_points(self): 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]], - }, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"]["2t"]["values"] == range_vals - assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_REFORECAST_METADATA} + assert cov["mars:metadata"] == {"Forecast date": fc_date, **REFORECAST_METADATA_BASE} diff --git a/tests/test_encoder_wkt_from_polytope.py b/tests/test_encoder_wkt_from_polytope.py index 78fb76e..0862f3f 100644 --- a/tests/test_encoder_wkt_from_polytope.py +++ b/tests/test_encoder_wkt_from_polytope.py @@ -1,46 +1,18 @@ import numpy as np -from conftest import chain, make_point, node, tip -from polytope_feature.datacube.tensor_index_tree import TensorIndexTree +from conftest import ( + COMPOSITE_TWO_POINTS_XYZ, + REFORECAST_METADATA_BASE, + forecast_tree, + reforecast_branch, + reforecast_tree, +) 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])) - + 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 @@ -48,7 +20,7 @@ def test_single_date_single_step_two_points(self): assert cov["domain"]["axes"] == { "t": {"values": ["2025-01-01T00:00:00Z"]}, - "composite": COMPOSITE_TWO_POINTS, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"] == { @@ -76,22 +48,12 @@ def test_single_date_single_step_two_points(self): 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",)), + 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), + ] ) - 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) @@ -100,7 +62,7 @@ def test_reforecast_single_hdate_two_points(self): assert cov["domain"]["axes"] == { "t": {"values": ["2025-07-14T06:00:00Z"]}, - "composite": COMPOSITE_TWO_POINTS, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"] == { @@ -115,35 +77,17 @@ def test_reforecast_single_hdate_two_points(self): assert cov["mars:metadata"] == { "Forecast date": "2025-07-14T06:00:00Z", - **EXPECTED_REFORECAST_METADATA, + **REFORECAST_METADATA_BASE, } def test_reforecast_two_hdates_two_points(self): - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), + 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])]), + ] ) - 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) @@ -155,7 +99,7 @@ def test_reforecast_two_hdates_two_points(self): 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, + "composite": COMPOSITE_TWO_POINTS_XYZ, } assert cov["ranges"]["2t"]["values"] == range_vals - assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_REFORECAST_METADATA} + assert cov["mars:metadata"] == {"Forecast date": fc_date, **REFORECAST_METADATA_BASE} From 4a955d651ad589d0611fcbf5d8d442d6def56c3e Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:17:39 +0200 Subject: [PATCH 20/22] fix: typo in original code --- covjsonkit/encoder/Circle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/covjsonkit/encoder/Circle.py b/covjsonkit/encoder/Circle.py index b74105d..4818140 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -112,7 +112,7 @@ def from_xarray(self, dataset): self.add_coverage(mars_metadata, coords, dv_dict) # Return the generated CoverageJSON - return self.covjsonå + return self.covjson def from_polytope(self, result, date_key: str = "date") -> dict: """Encode a polytope ``TensorIndexTree`` result into a MultiPoint (Circle) CoverageJSON collection.""" From 6d12df2b6d13e233fc41590ec8cd9d6fe5c75897 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:35:21 +0200 Subject: [PATCH 21/22] fix: install polytope-python in CI --- requirements.txt | 1 + 1 file changed, 1 insertion(+) 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 From e67bd5a1039d78c1ebfed4d24dda0069aae9d948 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Thu, 16 Apr 2026 13:41:18 +0000 Subject: [PATCH 22/22] Bump version --- covjsonkit/version.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 2266d4a..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",