From e7500966a429de2b5e8b68a9b2cc60e38efe2ab3 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Mon, 20 Apr 2026 13:12:11 +0000 Subject: [PATCH 1/5] Convert datetime if hdate to string --- covjsonkit/encoder/encoder.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index 2968dc6..26563ba 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from typing import Any +import numpy as np import orjson import pandas as pd from covjson_pydantic.coverage import CoverageCollection @@ -176,7 +177,10 @@ 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: - mars_metadata[child.axis.name] = child.values[0] + val = child.values[0] + if isinstance(val, np.datetime64): + val = str(val) + mars_metadata[child.axis.name] = val def handle_specific_axes(child): if child.axis.name == "latitude": @@ -272,7 +276,10 @@ def create_composite_key_step(date, level, num, para): def handle_non_leaf_node_step(child): non_leaf_axes = ["latitude", "longitude", "param", "date", "time"] if child.axis.name not in non_leaf_axes: - mars_metadata[child.axis.name] = child.values[0] + val = child.values[0] + if isinstance(val, np.datetime64): + val = str(val) + mars_metadata[child.axis.name] = val def handle_specific_axes_step(child): if child.axis.name == "latitude": @@ -441,7 +448,10 @@ def _ensure_date_keys(): def handle_non_leaf_node_month(child): non_leaf_axes = ["latitude", "longitude", "param", "year", "month"] if child.axis.name not in non_leaf_axes: - mars_metadata[child.axis.name] = child.values[0] + val = child.values[0] + if isinstance(val, np.datetime64): + val = str(val) + mars_metadata[child.axis.name] = val def handle_specific_axes_month(child): if child.axis.name == "latitude": From 2ea9bf36f41b9fc1560da5de854502c46168e685 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Mon, 20 Apr 2026 13:14:55 +0000 Subject: [PATCH 2/5] Add test to ensure coveragejson produced is serialisable --- .../test_encoder_time_series_from_polytope.py | 169 +++--------------- 1 file changed, 20 insertions(+), 149 deletions(-) diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 4d8fc51..2d32035 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -1,4 +1,7 @@ +import json + import numpy as np +import orjson from conftest import chain, make_leaf, make_point, node, tip from polytope_feature.datacube.tensor_index_tree import TensorIndexTree @@ -29,7 +32,7 @@ def hdate_branch(hdate, lat, lon, result): EXPECTED_HDATE_METADATA = { "class": "ce", - "date": np.datetime64("2024-03-01"), + "date": "2024-03-01", "domain": "g", "expver": "4321", "levtype": "sfc", @@ -252,164 +255,32 @@ def test_multiple_times(self): assert cov["ranges"]["dis06"]["values"] == vals assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} - def test_multiple_hdates(self): - # 2 hdates (different days), 1 point → 2 coverages - tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) - date = tip(tree) - date.add_child(hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) - date.add_child(hdate_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [55.30])) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - expected = [ - (["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), - (["2025-07-15T12:00:00Z"], [55.30], "2025-07-15T06:00:00Z"), - ] - assert len(covjson["coverages"]) == len(expected) - for cov, (t, vals, fc_date) in zip(covjson["coverages"], expected): - assert cov["domain"]["axes"]["t"]["values"] == t - assert cov["ranges"]["dis06"]["values"] == vals - assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} - - def test_two_points(self): - # 1 hdate, 2 points → 2 coverages (one per point) + def test_reforecast_covjson_is_json_serialisable(self): + """CoverageJSON produced by from_polytope_reforecast must be serialisable + with both stdlib json and orjson (i.e. no numpy.datetime64 left in the + mars:metadata dict — regression test for the date axis leak).""" tree = chain( TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),)), - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), - *[node(n, v) for n, v in HDATE_SUFFIX], - ) - sfo = tip(tree) - sfo.add_child(make_point(51.5, 6.5, [42.17])) - sfo.add_child(make_point(52.0, 7.0, [38.91])) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - expected = [ - (51.5, 6.5, [42.17]), - (52.0, 7.0, [38.91]), - ] - assert len(covjson["coverages"]) == len(expected) - for cov, (lat, lon, vals) in zip(covjson["coverages"], expected): - assert cov["domain"]["axes"] == { - "latitude": {"values": [lat]}, - "longitude": {"values": [lon]}, - "levelist": {"values": [0]}, - "t": {"values": ["2025-07-14T12:00:00Z"]}, - } - assert cov["ranges"]["dis06"]["values"] == vals - assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - def test_two_points_two_times(self): - # 2 hdate values × 2 points → 4 coverages (point × hdate) - tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) - date = tip(tree) - - for hdate_val, vals in [ - (np.datetime64("2025-07-14T06:00:00"), [42.17, 38.91]), - (np.datetime64("2025-07-14T12:00:00"), [55.30, 49.62]), - ]: - branch = chain( - node("hdate", (hdate_val,)), - *[node(n, v) for n, v in HDATE_SUFFIX], - ) - sfo = tip(branch) - sfo.add_child(make_point(51.5, 6.5, [vals[0]])) - sfo.add_child(make_point(52.0, 7.0, [vals[1]])) - date.add_child(branch) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - expected = [ - (51.5, ["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), - (51.5, ["2025-07-14T18:00:00Z"], [55.30], "2025-07-14T12:00:00Z"), - (52.0, ["2025-07-14T12:00:00Z"], [38.91], "2025-07-14T06:00:00Z"), - (52.0, ["2025-07-14T18:00:00Z"], [49.62], "2025-07-14T12:00:00Z"), - ] - assert len(covjson["coverages"]) == len(expected) - for cov, (lat, t, vals, fc_date) in zip(covjson["coverages"], expected): - assert cov["domain"]["axes"]["latitude"]["values"] == [lat] - assert cov["domain"]["axes"]["t"]["values"] == t - assert cov["ranges"]["dis06"]["values"] == vals - assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} - - def test_multiple_steps(self): - """Single hdate, two steps (6h, 12h), single point → 1 coverage with 2 t-values.""" - suffix = [ - ("domain", ("g",)), - ("expver", ("4321",)), - ("levtype", ("sfc",)), - ("model", ("lisflood",)), - ("origin", ("ecmf",)), - ("param", ("240023",)), - ("step", (6, 12)), - ("stream", ("efcl",)), - ("type", ("sfo",)), - ] - - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), - *[node(n, v) for n, v in suffix], - make_point(51.5, 6.5, [42.17, 55.30]), + hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), ) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - - assert cov["domain"]["axes"] == { - "latitude": {"values": [51.5]}, - "longitude": {"values": [6.5]}, - "levelist": {"values": [0]}, - "t": {"values": ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"]}, - } - assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] - assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} - - def test_multiple_params(self): - """Two parameters in a single hdate subtree.""" - suffix = [ - ("domain", ("g",)), - ("expver", ("4321",)), - ("levtype", ("sfc",)), - ("model", ("lisflood",)), - ("origin", ("ecmf",)), - ("param", ("240023", "231002")), - ("step", (6,)), - ("stream", ("efcl",)), - ("type", ("sfo",)), - ] - - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), - *[node(n, v) for n, v in suffix], - make_point(51.5, 6.5, [42.17, 99.5]), - ) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + # stdlib json.dumps must not raise TypeError + serialised = json.dumps(covjson) + assert serialised # non-empty string - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] + # orjson.dumps must also succeed + orjson.dumps(covjson) - 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} + # The 'date' value in mars:metadata must be a plain string, not numpy.datetime64 + for cov in covjson["coverages"]: + date_val = cov["mars:metadata"].get("date") + assert not isinstance( + date_val, np.datetime64 + ), f"mars:metadata['date'] is still a numpy.datetime64: {date_val!r}" def test_multiple_hdates_and_steps(self): """4 hdates × 2 steps (6, 12) × 1 point → 4 coverages, each with 2 t-values.""" From 23788f6c293c3b025924a0b3c5732d099e5d7d04 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Mon, 20 Apr 2026 13:20:24 +0000 Subject: [PATCH 3/5] Update tests to use serialisable date --- tests/conftest.py | 2 +- tests/geojsontest.py | 16 ++++++++++++++++ tests/test_encoder_bounding_box_from_polytope.py | 2 +- tests/test_encoder_position_from_polytope.py | 4 ++-- ...est_encoder_vertical_profile_from_polytope.py | 2 +- 5 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 tests/geojsontest.py diff --git a/tests/conftest.py b/tests/conftest.py index 79e3744..ce742e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ REFORECAST_METADATA_BASE = { "class": "ce", - "date": np.datetime64("2024-03-01"), + "date": "2024-03-01", "domain": "g", "expver": "4321", "levtype": "sfc", diff --git a/tests/geojsontest.py b/tests/geojsontest.py new file mode 100644 index 0000000..6b6c973 --- /dev/null +++ b/tests/geojsontest.py @@ -0,0 +1,16 @@ +import json +import os + +from covjsonkit.api import Covjsonkit + +current_dir = os.path.dirname(__file__) +timeseries = os.path.join(current_dir, "data/test_timeseries_coverage.json") +with open(timeseries, "r") as f: + timeseries = json.load(f) + + +cov = Covjsonkit().decode(timeseries) +ts = cov.to_geojson() +print(ts) +assert ts["type"] == "FeatureCollection" +assert len(ts["features"]) == 16 diff --git a/tests/test_encoder_bounding_box_from_polytope.py b/tests/test_encoder_bounding_box_from_polytope.py index 0739a54..ec21c75 100644 --- a/tests/test_encoder_bounding_box_from_polytope.py +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -22,7 +22,7 @@ EXPECTED_REFORECAST_METADATA = { "class": "ce", - "date": np.datetime64("2024-03-01"), + "date": "2024-03-01", "domain": "g", "expver": "4321", "levtype": "sfc", diff --git a/tests/test_encoder_position_from_polytope.py b/tests/test_encoder_position_from_polytope.py index 3d33a11..0c14a3f 100644 --- a/tests/test_encoder_position_from_polytope.py +++ b/tests/test_encoder_position_from_polytope.py @@ -130,7 +130,7 @@ def test_reforecast_single_hdate_two_points(self): shared_metadata = { "class": "ce", - "date": np.datetime64("2024-03-01"), + "date": "2024-03-01", "Forecast date": "2025-07-14T06:00:00Z", "domain": "g", "expver": "4321", @@ -168,7 +168,7 @@ def test_reforecast_two_hdates_two_points(self): shared_metadata = { "class": "ce", - "date": np.datetime64("2024-03-01"), + "date": "2024-03-01", "domain": "g", "expver": "4321", "levtype": "sfc", diff --git a/tests/test_encoder_vertical_profile_from_polytope.py b/tests/test_encoder_vertical_profile_from_polytope.py index fde5c4e..7380fd4 100644 --- a/tests/test_encoder_vertical_profile_from_polytope.py +++ b/tests/test_encoder_vertical_profile_from_polytope.py @@ -200,7 +200,7 @@ class TestVerticalProfileFromPolytopeReforecast: EXPECTED_REFORECAST_METADATA = { "class": "ce", - "date": np.datetime64("2024-03-01"), + "date": "2024-03-01", "domain": "g", "expver": "4321", "levtype": "pl", From 16611eb32a15fa7c718a597c29ec411142c7d53b Mon Sep 17 00:00:00 2001 From: awarde96 Date: Thu, 23 Apr 2026 06:59:15 +0000 Subject: [PATCH 4/5] 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 699eb88..aa0be21 100644 --- a/covjsonkit/version.py +++ b/covjsonkit/version.py @@ -1 +1 @@ -__version__ = "0.2.16" +__version__ = "0.2.17" diff --git a/pyproject.toml b/pyproject.toml index 2f1c487..9349eba 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.16" +version = "0.2.17" dependencies = [ "pandas<3", "orjson", From a13251c54cfef213b7a9a96c46cf44e1c6a6f07c Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:02:57 +0200 Subject: [PATCH 5/5] Add back removed tests and parameterize new one --- .../test_encoder_time_series_from_polytope.py | 206 ++++++++++++++++-- 1 file changed, 187 insertions(+), 19 deletions(-) diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 2d32035..eefcba4 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -2,6 +2,7 @@ import numpy as np import orjson +import pytest from conftest import chain, make_leaf, make_point, node, tip from polytope_feature.datacube.tensor_index_tree import TensorIndexTree @@ -202,12 +203,13 @@ def test_multiple_params(self): class TestTimeseriesFromPolytopeReforecast: - def test_single_point(self): + @pytest.mark.parametrize("date", [np.datetime64("2024-03-01"), np.datetime64("2024-03-01T00:00:00")]) + def test_single_point(self, date): # 1 hdate (with time pre-merged by polytope-mars), 1 point tree = chain( TensorIndexTree(), node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), + node("date", (date,)), hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), ) @@ -234,7 +236,11 @@ def test_single_point(self): } } - assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + assert cov["mars:metadata"] == { + "Forecast date": "2025-07-14T06:00:00Z", + **EXPECTED_HDATE_METADATA, + "date": date.astype(str), + } def test_multiple_times(self): # 2 hdate values (pre-merged times from same day), 1 point → 2 coverages @@ -255,32 +261,164 @@ def test_multiple_times(self): assert cov["ranges"]["dis06"]["values"] == vals assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} - def test_reforecast_covjson_is_json_serialisable(self): - """CoverageJSON produced by from_polytope_reforecast must be serialisable - with both stdlib json and orjson (i.e. no numpy.datetime64 left in the - mars:metadata dict — regression test for the date axis leak).""" + def test_multiple_hdates(self): + # 2 hdates (different days), 1 point → 2 coverages + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + date.add_child(hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17])) + date.add_child(hdate_branch(np.datetime64("2025-07-15T06:00:00"), 51.5, 6.5, [55.30])) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), + (["2025-07-15T12:00:00Z"], [55.30], "2025-07-15T06:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"]["t"]["values"] == t + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} + + def test_two_points(self): + # 1 hdate, 2 points → 2 coverages (one per point) tree = chain( TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),)), - hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in HDATE_SUFFIX], ) + sfo = tip(tree) + sfo.add_child(make_point(51.5, 6.5, [42.17])) + sfo.add_child(make_point(52.0, 7.0, [38.91])) covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - # stdlib json.dumps must not raise TypeError - serialised = json.dumps(covjson) - assert serialised # non-empty string + expected = [ + (51.5, 6.5, [42.17]), + (52.0, 7.0, [38.91]), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, lon, vals) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"] == { + "latitude": {"values": [lat]}, + "longitude": {"values": [lon]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z"]}, + } + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_two_points_two_times(self): + # 2 hdate values × 2 points → 4 coverages (point × hdate) + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) - # orjson.dumps must also succeed - orjson.dumps(covjson) + for hdate_val, vals in [ + (np.datetime64("2025-07-14T06:00:00"), [42.17, 38.91]), + (np.datetime64("2025-07-14T12:00:00"), [55.30, 49.62]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + *[node(n, v) for n, v in HDATE_SUFFIX], + ) + sfo = tip(branch) + sfo.add_child(make_point(51.5, 6.5, [vals[0]])) + sfo.add_child(make_point(52.0, 7.0, [vals[1]])) + date.add_child(branch) - # The 'date' value in mars:metadata must be a plain string, not numpy.datetime64 - for cov in covjson["coverages"]: - date_val = cov["mars:metadata"].get("date") - assert not isinstance( - date_val, np.datetime64 - ), f"mars:metadata['date'] is still a numpy.datetime64: {date_val!r}" + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + expected = [ + (51.5, ["2025-07-14T12:00:00Z"], [42.17], "2025-07-14T06:00:00Z"), + (51.5, ["2025-07-14T18:00:00Z"], [55.30], "2025-07-14T12:00:00Z"), + (52.0, ["2025-07-14T12:00:00Z"], [38.91], "2025-07-14T06:00:00Z"), + (52.0, ["2025-07-14T18:00:00Z"], [49.62], "2025-07-14T12:00:00Z"), + ] + assert len(covjson["coverages"]) == len(expected) + for cov, (lat, t, vals, fc_date) in zip(covjson["coverages"], expected): + assert cov["domain"]["axes"]["latitude"]["values"] == [lat] + assert cov["domain"]["axes"]["t"]["values"] == t + assert cov["ranges"]["dis06"]["values"] == vals + assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} + + def test_multiple_steps(self): + """Single hdate, two steps (6h, 12h), single point → 1 coverage with 2 t-values.""" + suffix = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023",)), + ("step", (6, 12)), + ("stream", ("efcl",)), + ("type", ("sfo",)), + ] + + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in suffix], + make_point(51.5, 6.5, [42.17, 55.30]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [51.5]}, + "longitude": {"values": [6.5]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"]}, + } + assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} + + def test_multiple_params(self): + """Two parameters in a single hdate subtree.""" + suffix = [ + ("domain", ("g",)), + ("expver", ("4321",)), + ("levtype", ("sfc",)), + ("model", ("lisflood",)), + ("origin", ("ecmf",)), + ("param", ("240023", "231002")), + ("step", (6,)), + ("stream", ("efcl",)), + ("type", ("sfo",)), + ] + + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in suffix], + make_point(51.5, 6.5, [42.17, 99.5]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "latitude": {"values": [51.5]}, + "longitude": {"values": [6.5]}, + "levelist": {"values": [0]}, + "t": {"values": ["2025-07-14T12:00:00Z"]}, + } + assert cov["ranges"] == { + "dis06": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["dis06"], "values": [42.17]}, + "rowe": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["rowe"], "values": [99.5]}, + } + assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} def test_multiple_hdates_and_steps(self): """4 hdates × 2 steps (6, 12) × 1 point → 4 coverages, each with 2 t-values.""" @@ -324,3 +462,33 @@ def test_multiple_hdates_and_steps(self): assert cov["domain"]["axes"]["t"]["values"] == t assert cov["ranges"]["dis06"]["values"] == vals assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA} + + @pytest.mark.parametrize( + "json_module,date", [(json, np.datetime64("2024-03-01")), (orjson, np.datetime64("2024-03-01T00:00:00"))] + ) + def test_reforecast_covjson_is_json_serialisable(self, json_module, date): + """CoverageJSON produced by from_polytope_reforecast must be serialisable + with both stdlib json and orjson (i.e. no numpy.datetime64 left in the + mars:metadata dict — regression test for the date axis leak).""" + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (date,)), + hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + # stdlib json.dumps must not raise TypeError + serialised = json_module.dumps(covjson) + + # The 'date' value in mars:metadata must be a plain string, not numpy.datetime64 + for cov in covjson["coverages"]: + date_val = cov["mars:metadata"].get("date") + assert not isinstance( + date_val, np.datetime64 + ), f"mars:metadata['date'] is still a numpy.datetime64: {date_val!r}" + + # Round-trip through the JSON module to ensure it can be deserialised back to a dict + deserialised = json_module.loads(serialised) + assert deserialised == covjson