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": 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", 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_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 4d8fc51..eefcba4 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -1,4 +1,8 @@ +import json + 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 @@ -29,7 +33,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", @@ -199,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]), ) @@ -231,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 @@ -453,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 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",