From 71d3736fa22fb444fddc2481d9c456dda8df8039 Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:21:00 +0200 Subject: [PATCH 1/2] feat: make PointSeries CoverageJSON spec compliant --- covjsonkit/decoder/TimeSeries.py | 156 ++++---- covjsonkit/encoder/TimeSeries.py | 94 +++-- covjsonkit/encoder/encoder.py | 6 + tests/data/test_timeseries_coverage.json | 340 +++++++++++++++++- tests/data/test_timeseries_param_t.json | 71 +++- tests/data/test_timeseries_xyz_coverage.json | 96 +++-- tests/test_decoder_time_series.py | 53 +-- .../test_encoder_time_series_from_polytope.py | 193 ++++++++-- tests/test_geojson.py | 4 + tests/test_xarray_ts.py | 27 +- 10 files changed, 817 insertions(+), 223 deletions(-) diff --git a/covjsonkit/decoder/TimeSeries.py b/covjsonkit/decoder/TimeSeries.py index 28f5702..c9cebf7 100644 --- a/covjsonkit/decoder/TimeSeries.py +++ b/covjsonkit/decoder/TimeSeries.py @@ -9,18 +9,10 @@ def __init__(self, covjson): super().__init__(covjson) self.domains = self.get_domains() self.ranges = self.get_ranges() - if "x" in self.covjson["coverages"][0]["domain"]["axes"]: - self.x_name = "x" - else: - self.x_name = "latitude" - if "y" in self.covjson["coverages"][0]["domain"]["axes"]: - self.y_name = "y" - else: - self.y_name = "longitude" - if "z" in self.covjson["coverages"][0]["domain"]["axes"]: - self.z_name = "z" - else: - self.z_name = "levelist" + first_axes = self.covjson["coverages"][0]["domain"]["axes"] + self.x_name = "x" + self.y_name = "y" + self.z_name = "z" if "z" in first_axes else None def get_domains(self): domains = [] @@ -48,9 +40,9 @@ def get_coordinates(self): coord_dict[param] = [] # Get x,y,z,t coords and unpack t coords and match to x,y,z coords for ind, domain in enumerate(self.domains): - x = domain["axes"][self.x_name]["values"][0] - y = domain["axes"][self.y_name]["values"][0] - z = domain["axes"][self.z_name]["values"][0] + longitude = domain["axes"][self.x_name]["values"][0] + latitude = domain["axes"][self.y_name]["values"][0] + level = domain["axes"][self.z_name]["values"][0] if self.z_name else None fct = domain["axes"]["t"]["values"][0] ts = domain["axes"]["t"]["values"] if "number" in self.mars_metadata[ind]: @@ -62,7 +54,7 @@ def get_coordinates(self): for t in ts: # Have to replicate these coords for each parameter # coordinates.append([x, y, z, t]) - coords.append([x, y, z, fct, t, num]) + coords.append([latitude, longitude, level, fct, t, num]) coord_dict[param].append(coords) return coord_dict @@ -75,13 +67,16 @@ def to_geotiff(self): def to_geojson(self): features = [] for coverage in self.covjson["coverages"]: - lat = coverage["domain"]["axes"][self.x_name]["values"][0] - lon = coverage["domain"]["axes"][self.y_name]["values"][0] - z = coverage["domain"]["axes"][self.z_name]["values"][0] + longitude = coverage["domain"]["axes"][self.x_name]["values"][0] + latitude = coverage["domain"]["axes"][self.y_name]["values"][0] datetimes = coverage["domain"]["axes"]["t"]["values"] if "mars:metadata" in coverage: mars_metadata = coverage["mars:metadata"] + geom_coords = [longitude, latitude] + if self.z_name: + geom_coords.append(coverage["domain"]["axes"][self.z_name]["values"][0]) + values = {} for key in coverage["ranges"]: values[key] = coverage["ranges"][key]["values"] @@ -96,7 +91,7 @@ def to_geojson(self): features.append( { "type": "Feature", - "geometry": {"type": "Point", "coordinates": [lon, lat, z]}, + "geometry": {"type": "Point", "coordinates": geom_coords}, "properties": param_vals, } ) @@ -114,29 +109,31 @@ def to_xarray(self): if not has_forecast_date: return self._to_xarray_no_forecast_date() - dims = ["latitude", "longitude", "levelist", "number", "datetime", "t"] + if self.z_name: + dims = ["latitude", "longitude", "levelist", "number", "datetime", "t"] + else: + dims = ["latitude", "longitude", "number", "datetime", "t"] ds = [] - # Get coordinates for all domains all_coords = self.get_domains() - unique_coords = set() # To track unique coordinate tuples - unique_domains = [] # To store unique domains + unique_coords = set() + unique_domains = [] for domain in self.domains: - # Extract coordinate values - x = domain["axes"][self.x_name]["values"][0] - y = domain["axes"][self.y_name]["values"][0] - z = domain["axes"][self.z_name]["values"][0] - t = tuple(domain["axes"]["t"]["values"]) # Use tuple for hashable type + longitude = domain["axes"][self.x_name]["values"][0] + latitude = domain["axes"][self.y_name]["values"][0] + t = tuple(domain["axes"]["t"]["values"]) - # Create a unique identifier for the domain - coord_tuple = (x, y, z, t) + if self.z_name: + z = domain["axes"][self.z_name]["values"][0] + coord_tuple = (longitude, latitude, z, t) + else: + coord_tuple = (longitude, latitude, t) - # Check if this coordinate combination is already seen if coord_tuple not in unique_coords: - unique_coords.add(coord_tuple) # Mark as seen - unique_domains.append(domain) # Add to unique domains + unique_coords.add(coord_tuple) + unique_domains.append(domain) all_coords = unique_domains @@ -151,28 +148,36 @@ def to_xarray(self): # Process each coordinate domain for coords in all_coords: dataarraydict = {} - x = coords["axes"][self.x_name]["values"] - y = coords["axes"][self.y_name]["values"] - z = coords["axes"][self.z_name]["values"] + longitude = coords["axes"][self.x_name]["values"] + latitude = coords["axes"][self.y_name]["values"] steps = coords["axes"]["t"]["values"] steps = [step.replace("Z", "") for step in steps] steps = pd.to_datetime(steps) - cov_idx_list = self._find_coverages(nums, datetime, x, y, z) - - coords = { - "latitude": x, - "longitude": y, - "levelist": z, - "number": nums, - "datetime": datetime, - "t": steps, - } + if self.z_name: + z = coords["axes"][self.z_name]["values"] + cov_idx_list = self._find_coverages(nums, datetime, longitude, latitude, z) + coord_dict = { + "latitude": latitude, + "longitude": longitude, + "levelist": z, + "number": nums, + "datetime": datetime, + "t": steps, + } + else: + cov_idx_list = self._find_coverages(nums, datetime, longitude, latitude, None) + coord_dict = { + "latitude": latitude, + "longitude": longitude, + "number": nums, + "datetime": datetime, + "t": steps, + } for parameter in self.parameters: param_values = [[[] for _ in range(len(datetime))] for _ in range(len(nums))] - # Extract parameter values for the current domain for i, j, cov in cov_idx_list: param_values[i][j] = cov["ranges"][parameter]["values"] @@ -186,15 +191,13 @@ def to_xarray(self): "units": self.get_parameter_metadata(parameter)["unit"]["symbol"], "long_name": long_name, } - dataarraydict[long_name] = ( - dims, - [[[param_values]]], - attrs, - ) + if self.z_name: + dataarraydict[long_name] = (dims, [[[param_values]]], attrs) + else: + dataarraydict[long_name] = (dims, [[param_values]], attrs) - ds.append(xr.Dataset(data_vars=dataarraydict, coords=coords)) + ds.append(xr.Dataset(data_vars=dataarraydict, coords=coord_dict)) - # Combine all DataArrays into a Dataset for mars_metadata in self.mars_metadata[0]: if mars_metadata != "date" and mars_metadata != "step": for dss in ds: @@ -205,43 +208,37 @@ def to_xarray(self): return ds - def _find_coverages(self, nums, datetime, x, y, z): - """Find coverages that match the given domain parameters (num, date, x, y, z) - and return them along with domain parameter indices.""" + def _find_coverages(self, nums, datetime, longitude, latitude, z): result = [] for i, num in enumerate(nums): for j, date in enumerate(datetime): for coverage in self.covjson["coverages"]: - if self._covers_domain(coverage, num, date, x, y, z): + if self._covers_domain(coverage, num, date, longitude, latitude, z): result.append((i, j, coverage)) return result - def _covers_domain(self, coverage, num, date, x, y, z): - """check if coverage matches the given domain parameters (num, date, x, y, z)""" - return ( + def _covers_domain(self, coverage, num, date, longitude, latitude, z): + axes = coverage["domain"]["axes"] + match = ( coverage["mars:metadata"]["number"] == num and coverage["mars:metadata"]["Forecast date"] == date - and coverage["domain"]["axes"][self.x_name]["values"] == x - and coverage["domain"]["axes"][self.y_name]["values"] == y - and coverage["domain"]["axes"][self.z_name]["values"] == z + and axes[self.x_name]["values"] == longitude + and axes[self.y_name]["values"] == latitude ) + if match and self.z_name and z is not None: + match = axes[self.z_name]["values"] == z + return match def _to_xarray_no_forecast_date(self): - """Convert monthly-means CovJSON (no 'Forecast date' in metadata) to xarray. - - In this layout every coverage contains all of its time steps packed into - the domain's ``t`` axis (produced by ``from_polytope_month``). Each - coverage corresponds to one spatial point / ensemble member combination. - The resulting Dataset has a ``t`` dimension whose values come directly - from the domain axis. + """Monthly-means path: all time steps are packed into a single coverage's + t-axis and there is no "Forecast date" in mars:metadata. """ ds_list = [] for coverage in self.covjson["coverages"]: domain = coverage["domain"]["axes"] - x = domain[self.x_name]["values"] - y = domain[self.y_name]["values"] - z = domain[self.z_name]["values"] + longitude = domain[self.x_name]["values"] + latitude = domain[self.y_name]["values"] steps = domain["t"]["values"] steps = [s.replace("Z", "") for s in steps] @@ -266,10 +263,13 @@ def _to_xarray_no_forecast_date(self): dataarraydict[long_name] = dataarray coord_dict = dict( - latitude=(["latitude"], x), - longitude=(["longitude"], y), - levelist=(["levelist"], z), + latitude=(["latitude"], latitude), + longitude=(["longitude"], longitude), ) + if self.z_name: + z = domain[self.z_name]["values"] + coord_dict["levelist"] = (["levelist"], z) + dss = xr.Dataset(dataarraydict, coords=coord_dict) # Attach MARS metadata (skip keys that vary per time step) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 310f630..965e082 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -53,6 +53,53 @@ def add_range(self, coverage, values): def add_mars_metadata(self, coverage, metadata): coverage["mars:metadata"] = metadata + def _set_references(self, include_z): + refs = [ + { + "coordinates": ["x", "y"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + }, + { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, + }, + ] + if include_z: + refs.append( + { + "coordinates": ["z"], + "system": {"type": "VerticalCRS"}, + } + ) + self.covjson["referencing"] = refs + + def _add_coverage_from_tree(self, mars_metadata, coords, values, include_z): + new_coverage = {} + new_coverage["mars:metadata"] = mars_metadata + new_coverage["type"] = "Coverage" + domain_axes = { + "x": {"values": coords["longitude"]}, + "y": {"values": coords["latitude"]}, + } + if include_z: + domain_axes["z"] = {"values": coords["levelist"]} + domain_axes["t"] = {"values": coords["t"]} + new_coverage["domain"] = {"type": "Domain", "axes": domain_axes} + new_coverage["ranges"] = {} + for parameter in values.keys(): + param = self.convert_param_id_to_param(parameter) + new_coverage["ranges"][param] = { + "type": "NdArray", + "dataType": "float", + "shape": [len(values[parameter])], + "axisNames": ["t"], + "values": values[parameter], + } + self.covjson["coverages"].append(new_coverage) + def from_xarray(self, datasets): """ Converts an xarray dataset or a list of xarray datasets into an OGC CoverageJSON @@ -143,6 +190,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: fields["step"] = 0 fields["dates"] = [] fields["levels"] = [0] + fields["has_level_axis"] = False start = time.time() logging.debug("Tree walking starts at: %s", start) # noqa: E501 @@ -155,15 +203,8 @@ def from_polytope(self, result, date_key: str = "date") -> dict: 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", - }, - } - ) + include_z = fields["has_level_axis"] + self._set_references(include_z) coordinates = {} @@ -249,7 +290,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["levelist"] = level coordinates[date][i]["levelist"] = [level] del mm["step"] - self.add_coverage(mm, coordinates[date][i], val_dict) + self._add_coverage_from_tree(mm, coordinates[date][i], val_dict, include_z) end = time.time() delta = end - start @@ -276,6 +317,7 @@ def from_polytope_month(self, result): fields["months"] = [] fields["dates"] = [] # populated as "YYYY-MM" keys once both year and month are seen fields["levels"] = [0] + fields["has_level_axis"] = False start = time.time() logging.debug("Tree walking starts at: %s", start) @@ -287,15 +329,8 @@ def from_polytope_month(self, result): start = time.time() logging.debug("Coords creation: %s", start) - self.add_reference( - { - "coordinates": ["latitude", "longitude", "levelist"], - "system": { - "type": "GeographicCRS", - "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", - }, - } - ) + include_z = fields["has_level_axis"] + self._set_references(include_z) if fields["param"] == 0: raise ValueError("No data was returned.") @@ -361,7 +396,7 @@ def from_polytope_month(self, result): coord_entry = coordinates[first_date][i].copy() coord_entry["levelist"] = [level] coord_entry["t"] = [f"{date}-01T00:00:00Z" for date in fields["dates"]] - self.add_coverage(mm, coord_entry, val_dict) + self._add_coverage_from_tree(mm, coord_entry, val_dict, include_z) end = time.time() logging.debug("Coverage creation ends: %s", end) @@ -381,6 +416,7 @@ def from_polytope_step(self, result): fields["dates"] = [] fields["levels"] = [0] fields["times"] = [] + fields["has_level_axis"] = False start = time.time() logging.debug("Tree walking starts at: %s", start) # noqa: E501 @@ -393,15 +429,8 @@ def from_polytope_step(self, result): start = time.time() logging.debug("Coords creation: %s", start) # noqa: E501 - self.add_reference( - { - "coordinates": ["x", "y", "z"], - "system": { - "type": "GeographicCRS", - "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", - }, - } - ) + include_z = fields["has_level_axis"] + self._set_references(include_z) coordinates = {} @@ -465,7 +494,12 @@ def from_polytope_step(self, result): mm = mars_metadata.copy() mm["number"] = num mm["Forecast date"] = date - self.add_coverage(mm, coordinates[fields["dates"][0]][(i * len(fields["levels"]) + j)], val_dict) + self._add_coverage_from_tree( + mm, + coordinates[fields["dates"][0]][(i * len(fields["levels"]) + j)], + val_dict, + include_z, + ) end = time.time() delta = end - start diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index 0332de2..d2c544e 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -392,6 +392,8 @@ def append_composite_coords(dates, tree_values, lat, coords): fields["lat"] = result elif child.axis.name == "levelist": fields["levels"] = result + if "has_level_axis" in fields: + fields["has_level_axis"] = True if "l" in fields: fields["l"].extend(result) elif child.axis.name == "param": @@ -505,6 +507,8 @@ def append_composite_coords_step(dates, tree_values, lat, coords): fields["lat"] = result elif child.axis.name == "levelist": fields["levels"] = result + if "has_level_axis" in fields: + fields["has_level_axis"] = True if "l" in fields: fields["l"].extend(result) elif child.axis.name == "param": @@ -653,6 +657,8 @@ def append_composite_coords_month(date_key, tree_values, lat): fields["lat"] = result elif child.axis.name == "levelist": fields["levels"] = result + if "has_level_axis" in fields: + fields["has_level_axis"] = True if "l" in fields: fields["l"].extend(result) elif child.axis.name == "param": diff --git a/tests/data/test_timeseries_coverage.json b/tests/data/test_timeseries_coverage.json index 0452342..ba1a756 100644 --- a/tests/data/test_timeseries_coverage.json +++ b/tests/data/test_timeseries_coverage.json @@ -1 +1,339 @@ -{"type": "CoverageCollection", "domainType": "PointSeries", "coverages": [{"mars:metadata": {"class": "od", "Forecast date": "2025-06-23T00:00:00Z", "domain": "g", "expver": "0001", "levtype": "sfc", "number": 1, "stream": "enfo", "type": "pf"}, "type": "Coverage", "domain": {"type": "Domain", "axes": {"latitude": {"values": [-0.035149384216]}, "longitude": {"values": [0.981308411215]}, "levelist": {"values": [0]}, "t": {"values": ["2025-06-23T00:00:00Z", "2025-06-23T01:00:00Z", "2025-06-23T02:00:00Z", "2025-06-23T03:00:00Z"]}}}, "ranges": {"tcc": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["tcc"], "values": [0.31280517578125, 0.45086669921875, 0.421661376953125, 0.233123779296875]}, "2t": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["2t"], "values": [299.13653564453125, 299.2686462402344, 298.88636779785156, 298.9047088623047]}}}, {"mars:metadata": {"class": "od", "Forecast date": "2025-06-23T00:00:00Z", "domain": "g", "expver": "0001", "levtype": "sfc", "number": 2, "stream": "enfo", "type": "pf"}, "type": "Coverage", "domain": {"type": "Domain", "axes": {"latitude": {"values": [-0.035149384216]}, "longitude": {"values": [0.981308411215]}, "levelist": {"values": [0]}, "t": {"values": ["2025-06-23T00:00:00Z", "2025-06-23T01:00:00Z", "2025-06-23T02:00:00Z", "2025-06-23T03:00:00Z"]}}}, "ranges": {"tcc": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["tcc"], "values": [0.831947922706604, 0.763214111328125, 0.762786865234375, 0.990081787109375]}, "2t": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["2t"], "values": [298.76365661621094, 298.6814422607422, 298.86614990234375, 298.78407287597656]}}}, {"mars:metadata": {"class": "od", "Forecast date": "2025-06-23T00:00:00Z", "domain": "g", "expver": "0001", "levtype": "sfc", "number": 1, "stream": "enfo", "type": "pf"}, "type": "Coverage", "domain": {"type": "Domain", "axes": {"latitude": {"values": [38.769770651632]}, "longitude": {"values": [350.914051841746]}, "levelist": {"values": [0]}, "t": {"values": ["2025-06-23T00:00:00Z", "2025-06-23T01:00:00Z", "2025-06-23T02:00:00Z", "2025-06-23T03:00:00Z"]}}}, "ranges": {"tcc": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["tcc"], "values": [1.0, 1.0, 0.205352783203125, 0.28228759765625]}, "2t": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["2t"], "values": [292.08770751953125, 292.0010681152344, 292.27699279785156, 292.2133026123047]}}}, {"mars:metadata": {"class": "od", "Forecast date": "2025-06-23T00:00:00Z", "domain": "g", "expver": "0001", "levtype": "sfc", "number": 2, "stream": "enfo", "type": "pf"}, "type": "Coverage", "domain": {"type": "Domain", "axes": {"latitude": {"values": [38.769770651632]}, "longitude": {"values": [350.914051841746]}, "levelist": {"values": [0]}, "t": {"values": ["2025-06-23T00:00:00Z", "2025-06-23T01:00:00Z", "2025-06-23T02:00:00Z", "2025-06-23T03:00:00Z"]}}}, "ranges": {"tcc": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["tcc"], "values": [0.9999929666519165, 1.0, 0.590179443359375, 0.099029541015625]}, "2t": {"type": "NdArray", "dataType": "float", "shape": [4], "axisNames": ["2t"], "values": [292.56053161621094, 292.1736297607422, 291.95794677734375, 291.95594787597656]}}}], "referencing": [{"coordinates": ["latitude", "longitude", "levelist"], "system": {"type": "GeographicCRS", "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"}}], "parameters": {"tcc": {"type": "Parameter", "description": {"en": "This parameter is the proportion of a grid box covered by cloud. Total cloud cover is a single level field calculated from the cloud occurring at different model levels through the atmosphere. Assumptions are made about the degree of overlap/randomness between clouds at different heights.

Cloud fractions vary from 0 to 1.\n
[NOTE: See 228164 for the equivalent parameter in \"%\"]"}, "unit": {"symbol": "(0 - 1)"}, "observedProperty": {"id": "tcc", "label": {"en": "Total cloud cover"}}}, "2t": {"type": "Parameter", "description": {"en": "This parameter is the temperature of air at 2m above the surface of land, sea or in-land waters.

2m temperature is calculated by interpolating between the lowest model level and the Earth's surface, taking account of the atmospheric conditions. See further information .

This parameter has units of kelvin (K). Temperature measured in kelvin can be converted to degrees Celsius (\u00b0C) by subtracting 273.15.

Please note that the encodings listed here for s2s & uerra (which includes encodings for carra/cerra) include entries for Mean 2 metre temperature. The specific encoding for Mean 2 metre temperature can be found in 228004."}, "unit": {"symbol": "K"}, "observedProperty": {"id": "2t", "label": {"en": "2 metre temperature"}}}}} +{ + "coverages": [ + { + "domain": { + "axes": { + "t": { + "values": [ + "2025-06-23T00:00:00Z", + "2025-06-23T01:00:00Z", + "2025-06-23T02:00:00Z", + "2025-06-23T03:00:00Z" + ] + }, + "x": { + "values": [ + 0.981308411215 + ] + }, + "y": { + "values": [ + -0.035149384216 + ] + } + }, + "type": "Domain" + }, + "mars:metadata": { + "class": "od", + "domain": "g", + "expver": "0001", + "Forecast date": "2025-06-23T00:00:00Z", + "levtype": "sfc", + "number": 1, + "stream": "enfo", + "type": "pf" + }, + "ranges": { + "2t": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 299.13653564453125, + 299.2686462402344, + 298.88636779785156, + 298.9047088623047 + ] + }, + "tcc": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 0.31280517578125, + 0.45086669921875, + 0.421661376953125, + 0.233123779296875 + ] + } + }, + "type": "Coverage" + }, + { + "domain": { + "axes": { + "t": { + "values": [ + "2025-06-23T00:00:00Z", + "2025-06-23T01:00:00Z", + "2025-06-23T02:00:00Z", + "2025-06-23T03:00:00Z" + ] + }, + "x": { + "values": [ + 0.981308411215 + ] + }, + "y": { + "values": [ + -0.035149384216 + ] + } + }, + "type": "Domain" + }, + "mars:metadata": { + "class": "od", + "domain": "g", + "expver": "0001", + "Forecast date": "2025-06-23T00:00:00Z", + "levtype": "sfc", + "number": 2, + "stream": "enfo", + "type": "pf" + }, + "ranges": { + "2t": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 298.76365661621094, + 298.6814422607422, + 298.86614990234375, + 298.78407287597656 + ] + }, + "tcc": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 0.831947922706604, + 0.763214111328125, + 0.762786865234375, + 0.990081787109375 + ] + } + }, + "type": "Coverage" + }, + { + "domain": { + "axes": { + "t": { + "values": [ + "2025-06-23T00:00:00Z", + "2025-06-23T01:00:00Z", + "2025-06-23T02:00:00Z", + "2025-06-23T03:00:00Z" + ] + }, + "x": { + "values": [ + 350.914051841746 + ] + }, + "y": { + "values": [ + 38.769770651632 + ] + } + }, + "type": "Domain" + }, + "mars:metadata": { + "class": "od", + "domain": "g", + "expver": "0001", + "Forecast date": "2025-06-23T00:00:00Z", + "levtype": "sfc", + "number": 1, + "stream": "enfo", + "type": "pf" + }, + "ranges": { + "2t": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 292.08770751953125, + 292.0010681152344, + 292.27699279785156, + 292.2133026123047 + ] + }, + "tcc": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 1.0, + 1.0, + 0.205352783203125, + 0.28228759765625 + ] + } + }, + "type": "Coverage" + }, + { + "domain": { + "axes": { + "t": { + "values": [ + "2025-06-23T00:00:00Z", + "2025-06-23T01:00:00Z", + "2025-06-23T02:00:00Z", + "2025-06-23T03:00:00Z" + ] + }, + "x": { + "values": [ + 350.914051841746 + ] + }, + "y": { + "values": [ + 38.769770651632 + ] + } + }, + "type": "Domain" + }, + "mars:metadata": { + "class": "od", + "domain": "g", + "expver": "0001", + "Forecast date": "2025-06-23T00:00:00Z", + "levtype": "sfc", + "number": 2, + "stream": "enfo", + "type": "pf" + }, + "ranges": { + "2t": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 292.56053161621094, + 292.1736297607422, + 291.95794677734375, + 291.95594787597656 + ] + }, + "tcc": { + "axisNames": [ + "t" + ], + "dataType": "float", + "shape": [ + 4 + ], + "type": "NdArray", + "values": [ + 0.9999929666519165, + 1.0, + 0.590179443359375, + 0.099029541015625 + ] + } + }, + "type": "Coverage" + } + ], + "domainType": "PointSeries", + "parameters": { + "2t": { + "description": { + "en": "This parameter is the temperature of air at 2m above the surface of land, sea or in-land waters.

2m temperature is calculated by interpolating between the lowest model level and the Earth's surface, taking account of the atmospheric conditions. See further information .

This parameter has units of kelvin (K). Temperature measured in kelvin can be converted to degrees Celsius (\u00b0C) by subtracting 273.15.

Please note that the encodings listed here for s2s & uerra (which includes encodings for carra/cerra) include entries for Mean 2 metre temperature. The specific encoding for Mean 2 metre temperature can be found in 228004." + }, + "observedProperty": { + "id": "2t", + "label": { + "en": "2 metre temperature" + } + }, + "type": "Parameter", + "unit": { + "symbol": "K" + } + }, + "tcc": { + "description": { + "en": "This parameter is the proportion of a grid box covered by cloud. Total cloud cover is a single level field calculated from the cloud occurring at different model levels through the atmosphere. Assumptions are made about the degree of overlap/randomness between clouds at different heights.

Cloud fractions vary from 0 to 1.\n
[NOTE: See 228164 for the equivalent parameter in \"%\"]" + }, + "observedProperty": { + "id": "tcc", + "label": { + "en": "Total cloud cover" + } + }, + "type": "Parameter", + "unit": { + "symbol": "(0 - 1)" + } + } + }, + "referencing": [ + { + "coordinates": [ + "x", + "y" + ], + "system": { + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "type": "GeographicCRS" + } + }, + { + "coordinates": [ + "t" + ], + "system": { + "calendar": "Gregorian", + "type": "TemporalRS" + } + } + ], + "type": "CoverageCollection" +} \ No newline at end of file diff --git a/tests/data/test_timeseries_param_t.json b/tests/data/test_timeseries_param_t.json index 99b7f47..1ef5b47 100644 --- a/tests/data/test_timeseries_param_t.json +++ b/tests/data/test_timeseries_param_t.json @@ -11,36 +11,85 @@ "domain": { "type": "Domain", "axes": { - "latitude": {"values": [47.5]}, - "longitude": {"values": [8.5]}, - "levelist": {"values": [74]}, - "t": {"values": ["2026-05-04T18:00:00Z"]} + "x": { + "values": [ + 8.5 + ] + }, + "y": { + "values": [ + 47.5 + ] + }, + "z": { + "values": [ + 74 + ] + }, + "t": { + "values": [ + "2026-05-04T18:00:00Z" + ] + } } }, "ranges": { "t": { "type": "NdArray", "dataType": "float", - "shape": [1], - "axisNames": ["t"], - "values": [285.6] + "shape": [ + 1 + ], + "axisNames": [ + "t" + ], + "values": [ + 285.6 + ] } } } ], "referencing": [ { - "coordinates": ["latitude", "longitude", "levelist"], - "system": {"type": "GeographicCRS"} + "coordinates": [ + "x", + "y" + ], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + } + }, + { + "coordinates": [ + "t" + ], + "system": { + "type": "TemporalRS", + "calendar": "Gregorian" + } + }, + { + "coordinates": [ + "z" + ], + "system": { + "type": "VerticalCRS" + } } ], "parameters": { "t": { "type": "Parameter", - "unit": {"symbol": "K"}, + "unit": { + "symbol": "K" + }, "observedProperty": { "id": "t", - "label": {"en": "Temperature"} + "label": { + "en": "Temperature" + } } } } diff --git a/tests/data/test_timeseries_xyz_coverage.json b/tests/data/test_timeseries_xyz_coverage.json index cca640b..444db95 100644 --- a/tests/data/test_timeseries_xyz_coverage.json +++ b/tests/data/test_timeseries_xyz_coverage.json @@ -11,25 +11,26 @@ "levtype": "sfc", "number": 1, "stream": "enfo", - "type": "pf" + "type": "pf", + "levelist": 500 }, "type": "Coverage", "domain": { "type": "Domain", "axes": { - "latitude": { + "x": { "values": [ - -0.035149384216 + 0.981308411215 ] }, - "longitude": { + "y": { "values": [ - 0.981308411215 + -0.035149384216 ] }, - "levelist": { + "z": { "values": [ - 0 + 500 ] }, "t": { @@ -50,7 +51,7 @@ 4 ], "axisNames": [ - "tcc" + "t" ], "values": [ 0.31280517578125, @@ -66,7 +67,7 @@ 4 ], "axisNames": [ - "2t" + "t" ], "values": [ 299.13653564453125, @@ -86,25 +87,26 @@ "levtype": "sfc", "number": 2, "stream": "enfo", - "type": "pf" + "type": "pf", + "levelist": 500 }, "type": "Coverage", "domain": { "type": "Domain", "axes": { - "latitude": { + "x": { "values": [ - -0.035149384216 + 0.981308411215 ] }, - "longitude": { + "y": { "values": [ - 0.981308411215 + -0.035149384216 ] }, - "levelist": { + "z": { "values": [ - 0 + 500 ] }, "t": { @@ -125,7 +127,7 @@ 4 ], "axisNames": [ - "tcc" + "t" ], "values": [ 0.831947922706604, @@ -141,7 +143,7 @@ 4 ], "axisNames": [ - "2t" + "t" ], "values": [ 298.76365661621094, @@ -161,25 +163,26 @@ "levtype": "sfc", "number": 1, "stream": "enfo", - "type": "pf" + "type": "pf", + "levelist": 500 }, "type": "Coverage", "domain": { "type": "Domain", "axes": { - "latitude": { + "x": { "values": [ - 38.769770651632 + 350.914051841746 ] }, - "longitude": { + "y": { "values": [ - 350.914051841746 + 38.769770651632 ] }, - "levelist": { + "z": { "values": [ - 0 + 500 ] }, "t": { @@ -200,7 +203,7 @@ 4 ], "axisNames": [ - "tcc" + "t" ], "values": [ 1.0, @@ -216,7 +219,7 @@ 4 ], "axisNames": [ - "2t" + "t" ], "values": [ 292.08770751953125, @@ -236,25 +239,26 @@ "levtype": "sfc", "number": 2, "stream": "enfo", - "type": "pf" + "type": "pf", + "levelist": 500 }, "type": "Coverage", "domain": { "type": "Domain", "axes": { - "latitude": { + "x": { "values": [ - 38.769770651632 + 350.914051841746 ] }, - "longitude": { + "y": { "values": [ - 350.914051841746 + 38.769770651632 ] }, - "levelist": { + "z": { "values": [ - 0 + 500 ] }, "t": { @@ -275,7 +279,7 @@ 4 ], "axisNames": [ - "tcc" + "t" ], "values": [ 0.9999929666519165, @@ -291,7 +295,7 @@ 4 ], "axisNames": [ - "2t" + "t" ], "values": [ 292.56053161621094, @@ -307,13 +311,29 @@ { "coordinates": [ "x", - "y", - "z" + "y" ], "system": { "type": "GeographicCRS", "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" } + }, + { + "coordinates": [ + "t" + ], + "system": { + "type": "TemporalRS", + "calendar": "Gregorian" + } + }, + { + "coordinates": [ + "z" + ], + "system": { + "type": "VerticalCRS" + } } ], "parameters": { diff --git a/tests/test_decoder_time_series.py b/tests/test_decoder_time_series.py index 80cb0a2..8ed2dc4 100644 --- a/tests/test_decoder_time_series.py +++ b/tests/test_decoder_time_series.py @@ -25,9 +25,9 @@ def setup_method(self, method): "domain": { "type": "Domain", "axes": { - "latitude": {"values": [3]}, - "longitude": {"values": [7]}, - "levelist": {"values": [1]}, + "x": {"values": [7]}, + "y": {"values": [3]}, + "z": {"values": [1]}, "t": { "values": [ "2017-01-01 00:00:00", @@ -75,9 +75,9 @@ def setup_method(self, method): "domain": { "type": "Domain", "axes": { - "latitude": {"values": [3]}, - "longitude": {"values": [7]}, - "levelist": {"values": [1]}, + "x": {"values": [7]}, + "y": {"values": [3]}, + "z": {"values": [1]}, "t": { "values": [ "2017-01-02 00:00:00", @@ -115,12 +115,20 @@ def setup_method(self, method): ], "referencing": [ { - "coordinates": ["x", "y", "z"], + "coordinates": ["x", "y"], "system": { "type": "GeographicCRS", "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", }, - } + }, + { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, + }, + { + "coordinates": ["z"], + "system": {"type": "VerticalCRS"}, + }, ], "parameters": { "t": { @@ -150,7 +158,7 @@ def test_timeseries_parameters(self): def test_timeseries_referencing(self): decoder = Covjsonkit().decode(self.covjson) - assert decoder.get_referencing() == ["x", "y", "z"] + assert decoder.get_referencing() == ["x", "y", "t", "z"] def test_timeseries_mars_metadata(self): decoder = Covjsonkit().decode(self.covjson) @@ -177,9 +185,9 @@ def test_timeseries_domains(self): domain1 = { "type": "Domain", "axes": { - "latitude": {"values": [3]}, - "longitude": {"values": [7]}, - "levelist": {"values": [1]}, + "x": {"values": [7]}, + "y": {"values": [3]}, + "z": {"values": [1]}, "t": { "values": [ "2017-01-01 00:00:00", @@ -193,9 +201,9 @@ def test_timeseries_domains(self): domain2 = { "type": "Domain", "axes": { - "latitude": {"values": [3]}, - "longitude": {"values": [7]}, - "levelist": {"values": [1]}, + "x": {"values": [7]}, + "y": {"values": [3]}, + "z": {"values": [1]}, "t": { "values": [ "2017-01-02 00:00:00", @@ -342,9 +350,8 @@ def test_to_xarray_monthly_means(self): "domain": { "type": "Domain", "axes": { - "latitude": {"values": [9.896853442816]}, - "longitude": {"values": [9.84375]}, - "levelist": {"values": [0]}, + "x": {"values": [9.84375]}, + "y": {"values": [9.896853442816]}, "t": { "values": [ "2020-02-01T00:00:00Z", @@ -358,7 +365,7 @@ def test_to_xarray_monthly_means(self): "type": "NdArray", "dataType": "float", "shape": [2], - "axisNames": ["mean2t"], + "axisNames": ["t"], "values": [300.34325408935547, 301.6697769165039], } }, @@ -366,12 +373,16 @@ def test_to_xarray_monthly_means(self): ], "referencing": [ { - "coordinates": ["latitude", "longitude", "levelist"], + "coordinates": ["x", "y"], "system": { "type": "GeographicCRS", "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", }, - } + }, + { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, + }, ], "parameters": { "mean2t": { diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index eefcba4..f359238 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -72,9 +72,8 @@ def test_standard_forecast_single_point(self): cov = covjson["coverages"][0] assert cov["domain"]["axes"] == { - "latitude": {"values": [48.0]}, - "longitude": {"values": [11.0]}, - "levelist": {"values": [0]}, + "x": {"values": [11.0]}, + "y": {"values": [48.0]}, "t": {"values": ["2025-01-01T00:00:00Z", "2025-01-01T06:00:00Z"]}, } @@ -83,7 +82,7 @@ def test_standard_forecast_single_point(self): "type": "NdArray", "dataType": "float", "shape": [2], - "axisNames": ["2t"], + "axisNames": ["t"], "values": [264.931, 263.831], } } @@ -100,6 +99,20 @@ def test_standard_forecast_single_point(self): "levelist": 0, } + assert covjson["referencing"] == [ + { + "coordinates": ["x", "y"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + }, + { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, + }, + ] + def test_standard_forecast_multiple_coverages(self): # ce/efas/fc/sfc flood forecast: 2 dates × 2 steps × 2 points → 4 coverages tree = chain(TensorIndexTree(), node("class", ("ce",))) @@ -150,9 +163,8 @@ def test_standard_forecast_multiple_coverages(self): 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]}, + "x": {"values": [lon]}, + "y": {"values": [lat]}, "t": {"values": t}, } assert cov["ranges"]["dis06"]["values"] == vals @@ -180,14 +192,13 @@ def test_multiple_params(self): cov = covjson["coverages"][0] assert cov["domain"]["axes"] == { - "latitude": {"values": [48.0]}, - "longitude": {"values": [11.0]}, - "levelist": {"values": [0]}, + "x": {"values": [11.0]}, + "y": {"values": [48.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]}, + "2t": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["t"], "values": [264.9]}, + "2d": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["t"], "values": [250.1]}, } assert cov["mars:metadata"] == { "class": "od", @@ -201,6 +212,40 @@ def test_multiple_params(self): "levelist": 0, } + def test_real_level_single_point(self): + 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("levelist", (850,)), + node("param", ("167",)), + node("step", (0,)), + node("stream", ("oper",)), + node("type", ("fc",)), + make_point(48.0, 11.0, [264.931]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert cov["domain"]["axes"] == { + "x": {"values": [11.0]}, + "y": {"values": [48.0]}, + "z": {"values": [850]}, + "t": {"values": ["2025-01-01T00:00:00Z"]}, + } + assert covjson["referencing"][-1] == { + "coordinates": ["z"], + "system": {"type": "VerticalCRS"}, + } + assert cov["ranges"]["2t"]["axisNames"] == ["t"] + assert cov["mars:metadata"]["levelist"] == 850 + class TestTimeseriesFromPolytopeReforecast: @pytest.mark.parametrize("date", [np.datetime64("2024-03-01"), np.datetime64("2024-03-01T00:00:00")]) @@ -220,9 +265,8 @@ def test_single_point(self, date): # t = hdate(2025-07-14T06:00) + step(6h) = 2025-07-14T12:00:00Z assert cov["domain"]["axes"] == { - "latitude": {"values": [51.5]}, - "longitude": {"values": [6.5]}, - "levelist": {"values": [0]}, + "x": {"values": [6.5]}, + "y": {"values": [51.5]}, "t": {"values": ["2025-07-14T12:00:00Z"]}, } @@ -231,7 +275,7 @@ def test_single_point(self, date): "type": "NdArray", "dataType": "float", "shape": [1], - "axisNames": ["dis06"], + "axisNames": ["t"], "values": [42.17], } } @@ -302,9 +346,8 @@ def test_two_points(self): 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]}, + "x": {"values": [lon]}, + "y": {"values": [lat]}, "t": {"values": ["2025-07-14T12:00:00Z"]}, } assert cov["ranges"]["dis06"]["values"] == vals @@ -338,7 +381,7 @@ def test_two_points_two_times(self): ] 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"]["y"]["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} @@ -372,9 +415,8 @@ def test_multiple_steps(self): cov = covjson["coverages"][0] assert cov["domain"]["axes"] == { - "latitude": {"values": [51.5]}, - "longitude": {"values": [6.5]}, - "levelist": {"values": [0]}, + "x": {"values": [6.5]}, + "y": {"values": [51.5]}, "t": {"values": ["2025-07-14T12:00:00Z", "2025-07-14T18:00:00Z"]}, } assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] @@ -409,14 +451,13 @@ def test_multiple_params(self): cov = covjson["coverages"][0] assert cov["domain"]["axes"] == { - "latitude": {"values": [51.5]}, - "longitude": {"values": [6.5]}, - "levelist": {"values": [0]}, + "x": {"values": [6.5]}, + "y": {"values": [51.5]}, "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]}, + "dis06": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["t"], "values": [42.17]}, + "rowe": {"type": "NdArray", "dataType": "float", "shape": [1], "axisNames": ["t"], "values": [99.5]}, } assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA} @@ -492,3 +533,99 @@ def test_reforecast_covjson_is_json_serialisable(self, json_module, date): # Round-trip through the JSON module to ensure it can be deserialised back to a dict deserialised = json_module.loads(serialised) assert deserialised == covjson + + +class TestTimeseriesFromPolytopeMonthly: + def test_monthly_surface_x_y_t(self): + """from_polytope_month: one year, two months, surface → x/y/t axes, no z, two references.""" + tree = chain( + TensorIndexTree(), + node("class", ("d1",)), + node("stream", ("clmn",)), + node("levtype", ("sfc",)), + node("number", (0,)), + node("year", (2020,)), + node("month", (2, 3)), + node("param", ("167",)), + make_point(48.0, 11.0, [300.0, 301.0]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_month(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert set(cov["domain"]["axes"].keys()) == {"x", "y", "t"} + assert cov["domain"]["axes"]["x"] == {"values": [11.0]} + assert cov["domain"]["axes"]["y"] == {"values": [48.0]} + assert "z" not in cov["domain"]["axes"] + assert cov["domain"]["axes"]["t"]["values"] == [ + "2020-02-01T00:00:00Z", + "2020-03-01T00:00:00Z", + ] + + assert cov["ranges"]["2t"]["axisNames"] == ["t"] + assert cov["ranges"]["2t"]["shape"] == [2] + + assert covjson["referencing"] == [ + { + "coordinates": ["x", "y"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + }, + { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, + }, + ] + + +class TestTimeseriesFromPolytopeStep: + def test_step_surface_x_y_t(self): + """from_polytope_step: one date, two time offsets, surface → x/y/t axes, no z, two references.""" + from datetime import timedelta + + 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",)), + node("time", (timedelta(0), timedelta(hours=6))), + make_point(48.0, 11.0, [264.0, 263.0]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_step(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + + assert set(cov["domain"]["axes"].keys()) == {"x", "y", "t"} + assert cov["domain"]["axes"]["x"] == {"values": [11.0]} + assert cov["domain"]["axes"]["y"] == {"values": [48.0]} + assert "z" not in cov["domain"]["axes"] + assert len(cov["domain"]["axes"]["t"]["values"]) == 2 + + assert cov["ranges"]["2t"]["axisNames"] == ["t"] + assert cov["ranges"]["2t"]["shape"] == [2] + + assert covjson["referencing"] == [ + { + "coordinates": ["x", "y"], + "system": { + "type": "GeographicCRS", + "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + }, + { + "coordinates": ["t"], + "system": {"type": "TemporalRS", "calendar": "Gregorian"}, + }, + ] diff --git a/tests/test_geojson.py b/tests/test_geojson.py index 2240c61..5703f80 100644 --- a/tests/test_geojson.py +++ b/tests/test_geojson.py @@ -36,12 +36,16 @@ def test_geojson_timeseries(self): ts = cov.to_geojson() assert ts["type"] == "FeatureCollection" assert len(ts["features"]) == 16 + coords = ts["features"][0]["geometry"]["coordinates"] + assert len(coords) == 2 def test_geojson_xyz_axes_timeseries(self): cov = Covjsonkit().decode(self.timeseries_xyz) ts = cov.to_geojson() assert ts["type"] == "FeatureCollection" assert len(ts["features"]) == 16 + coords = ts["features"][0]["geometry"]["coordinates"] + assert len(coords) == 3 def test_geojson_verticalprofile(self): cov = Covjsonkit().decode(self.verticalprofile) diff --git a/tests/test_xarray_ts.py b/tests/test_xarray_ts.py index 9e8ea44..60ff3dc 100644 --- a/tests/test_xarray_ts.py +++ b/tests/test_xarray_ts.py @@ -8,10 +8,12 @@ class TestPointSeriesXarray: def setup_method(self): current_dir = os.path.dirname(__file__) file_path = os.path.join(current_dir, "data/test_timeseries_coverage.json") - with open(file_path, "r") as f: - result = json.load(f) - self.test_covjson = result + self.test_covjson = json.load(f) + + xyz_path = os.path.join(current_dir, "data/test_timeseries_xyz_coverage.json") + with open(xyz_path, "r") as f: + self.test_covjson_xyz = json.load(f) def test_to_xarray(self): decoder_obj = Covjsonkit().decode(self.test_covjson) @@ -19,33 +21,26 @@ def test_to_xarray(self): assert len(ds) == 2 assert ds[0].latitude.values[0] == -0.035149384216 assert ds[0].longitude.values[0] == 0.981308411215 + assert "levelist" not in ds[0].dims assert len(ds[0].number.values) == 2 assert len(ds[0].t.values) == 4 encoder_obj = Covjsonkit().encode("CoverageCollection", "PointSeries") assert encoder_obj.covjson["type"] == "CoverageCollection" def test_from_xarray(self): - decoder_obj = Covjsonkit().decode(self.test_covjson) + decoder_obj = Covjsonkit().decode(self.test_covjson_xyz) ds = decoder_obj.to_xarray() encoder_obj = Covjsonkit().encode("CoverageCollection", "PointSeries") covjson_result = encoder_obj.from_xarray(ds) - assert covjson_result["type"] == self.test_covjson["type"] - assert len(covjson_result["coverages"]) == len(self.test_covjson["coverages"]) - assert ( - covjson_result["coverages"][0]["domain"]["axes"]["latitude"]["values"][0] - == self.test_covjson["coverages"][0]["domain"]["axes"]["latitude"]["values"][0] - ) - assert ( - covjson_result["coverages"][0]["domain"]["axes"]["longitude"]["values"][0] - == self.test_covjson["coverages"][0]["domain"]["axes"]["longitude"]["values"][0] - ) + assert covjson_result["type"] == self.test_covjson_xyz["type"] + assert len(covjson_result["coverages"]) == len(self.test_covjson_xyz["coverages"]) assert ( covjson_result["coverages"][0]["mars:metadata"]["number"] - == self.test_covjson["coverages"][0]["mars:metadata"]["number"] + == self.test_covjson_xyz["coverages"][0]["mars:metadata"]["number"] ) assert ( covjson_result["coverages"][0]["ranges"]["2t"]["values"][0] - == self.test_covjson["coverages"][0]["ranges"]["2t"]["values"][0] + == self.test_covjson_xyz["coverages"][0]["ranges"]["2t"]["values"][0] ) From f3087b4b7e4c417fcff13406f85831c5abd5c69d Mon Sep 17 00:00:00 2001 From: Andreas Grafberger <18516896+andreas-grafberger@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:29:51 +0200 Subject: [PATCH 2/2] tidy: simplify a bit --- covjsonkit/encoder/TimeSeries.py | 78 ++++++------------------ tests/data/test_timeseries_coverage.json | 2 +- tests/test_encoder_time_series.py | 10 ++- tests/test_xarray_ts.py | 9 +++ 4 files changed, 35 insertions(+), 64 deletions(-) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 965e082..02c7193 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -13,30 +13,28 @@ def __init__(self, type, domaintype): self.covjson["domainType"] = "PointSeries" self.covjson["coverages"] = [] - def add_coverage(self, mars_metadata, coords, values): + def add_coverage(self, mars_metadata, coords, values, include_z=False): new_coverage = {} new_coverage["mars:metadata"] = {} new_coverage["type"] = "Coverage" new_coverage["domain"] = {} new_coverage["ranges"] = {} self.add_mars_metadata(new_coverage, mars_metadata) - self.add_domain(new_coverage, coords) + self.add_domain(new_coverage, coords, include_z) self.add_range(new_coverage, values) self.covjson["coverages"].append(new_coverage) # cov = Coverage.model_validate_json(json.dumps(new_coverage)) # self.pydantic_coverage.coverages.append(cov) - def add_domain(self, coverage, coords): + def add_domain(self, coverage, coords, include_z=False): coverage["domain"]["type"] = "Domain" - coverage["domain"]["axes"] = {} - coverage["domain"]["axes"]["latitude"] = {} - coverage["domain"]["axes"]["longitude"] = {} - coverage["domain"]["axes"]["levelist"] = {} - coverage["domain"]["axes"]["t"] = {} - coverage["domain"]["axes"]["latitude"]["values"] = coords["latitude"] - coverage["domain"]["axes"]["longitude"]["values"] = coords["longitude"] - coverage["domain"]["axes"]["levelist"]["values"] = coords["levelist"] - coverage["domain"]["axes"]["t"]["values"] = coords["t"] + axes = {} + axes["x"] = {"values": coords["longitude"]} + axes["y"] = {"values": coords["latitude"]} + if include_z: + axes["z"] = {"values": coords["levelist"]} + axes["t"] = {"values": coords["t"]} + coverage["domain"]["axes"] = axes def add_range(self, coverage, values): for parameter in values.keys(): @@ -45,7 +43,7 @@ def add_range(self, coverage, values): coverage["ranges"][param]["type"] = "NdArray" coverage["ranges"][param]["dataType"] = "float" coverage["ranges"][param]["shape"] = [len(values[parameter])] - coverage["ranges"][param]["axisNames"] = [str(param)] + coverage["ranges"][param]["axisNames"] = ["t"] coverage["ranges"][param]["values"] = values[ parameter ] # [values[parameter][val][0] for val in values[parameter].keys()] @@ -76,30 +74,6 @@ def _set_references(self, include_z): ) self.covjson["referencing"] = refs - def _add_coverage_from_tree(self, mars_metadata, coords, values, include_z): - new_coverage = {} - new_coverage["mars:metadata"] = mars_metadata - new_coverage["type"] = "Coverage" - domain_axes = { - "x": {"values": coords["longitude"]}, - "y": {"values": coords["latitude"]}, - } - if include_z: - domain_axes["z"] = {"values": coords["levelist"]} - domain_axes["t"] = {"values": coords["t"]} - new_coverage["domain"] = {"type": "Domain", "axes": domain_axes} - new_coverage["ranges"] = {} - for parameter in values.keys(): - param = self.convert_param_id_to_param(parameter) - new_coverage["ranges"][param] = { - "type": "NdArray", - "dataType": "float", - "shape": [len(values[parameter])], - "axisNames": ["t"], - "values": values[parameter], - } - self.covjson["coverages"].append(new_coverage) - def from_xarray(self, datasets): """ Converts an xarray dataset or a list of xarray datasets into an OGC CoverageJSON @@ -118,27 +92,10 @@ def from_xarray(self, datasets): self.covjson["domainType"] = "PointSeries" self.covjson["coverages"] = [] - if "latitude" in datasets[0].coords: - x_coord = "latitude" - elif "x" in datasets[0].coords: - x_coord = "x" - if "longitude" in datasets[0].coords: - y_coord = "longitude" - elif "y" in datasets[0].coords: - y_coord = "y" - if "levelist" in datasets[0].coords: - z_coord = "levelist" + include_z = "levelist" in datasets[0].coords # Add reference system - self.add_reference( - { - "coordinates": [x_coord, y_coord, z_coord], - "system": { - "type": "GeographicCRS", - "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", - }, - } - ) + self._set_references(include_z) for data_var in datasets[0].data_vars: data_var = self.convert_param_to_param_id(data_var) @@ -162,10 +119,11 @@ def from_xarray(self, datasets): { "latitude": [float(x) for x in dataset["latitude"].values], "longitude": [float(x) for x in dataset["longitude"].values], - "levelist": [float(x) for x in dataset["levelist"].values], + "levelist": [float(x) for x in dataset["levelist"].values] if include_z else None, "t": [str(x) for x in dataset["t"].values], }, dv_dict, + include_z=include_z, ) return self.covjson @@ -290,7 +248,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["levelist"] = level coordinates[date][i]["levelist"] = [level] del mm["step"] - self._add_coverage_from_tree(mm, coordinates[date][i], val_dict, include_z) + self.add_coverage(mm, coordinates[date][i], val_dict, include_z) end = time.time() delta = end - start @@ -396,7 +354,7 @@ def from_polytope_month(self, result): coord_entry = coordinates[first_date][i].copy() coord_entry["levelist"] = [level] coord_entry["t"] = [f"{date}-01T00:00:00Z" for date in fields["dates"]] - self._add_coverage_from_tree(mm, coord_entry, val_dict, include_z) + self.add_coverage(mm, coord_entry, val_dict, include_z) end = time.time() logging.debug("Coverage creation ends: %s", end) @@ -494,7 +452,7 @@ def from_polytope_step(self, result): mm = mars_metadata.copy() mm["number"] = num mm["Forecast date"] = date - self._add_coverage_from_tree( + self.add_coverage( mm, coordinates[fields["dates"][0]][(i * len(fields["levels"]) + j)], val_dict, diff --git a/tests/data/test_timeseries_coverage.json b/tests/data/test_timeseries_coverage.json index ba1a756..799dccc 100644 --- a/tests/data/test_timeseries_coverage.json +++ b/tests/data/test_timeseries_coverage.json @@ -336,4 +336,4 @@ } ], "type": "CoverageCollection" -} \ No newline at end of file +} diff --git a/tests/test_encoder_time_series.py b/tests/test_encoder_time_series.py index f722b3c..6c3f840 100644 --- a/tests/test_encoder_time_series.py +++ b/tests/test_encoder_time_series.py @@ -190,7 +190,7 @@ def test_add_coverage(self): encoder.add_parameter(167) encoder.add_reference( { - "coordinates": ["x", "y", "z"], + "coordinates": ["x", "y"], "system": { "type": "GeographicCRS", "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", @@ -198,8 +198,8 @@ def test_add_coverage(self): } ) encoder.add_reference({"coordinates": ["t"], "system": {"type": "TemporalRS", "calendar": "Gregorian"}}) + encoder.add_reference({"coordinates": ["z"], "system": {"type": "VerticalCRS"}}) - # metadatas = [] coords = [] values = [] for number in range(0, 10): @@ -225,11 +225,15 @@ def test_add_coverage(self): coords.append(coord) value = {"2t": {0: [random.uniform(230, 270) for _ in range(0, len(timestamps))]}} values.append(value) - encoder.add_coverage(metadata, coord, value) + encoder.add_coverage(metadata, coord, value, include_z=True) json_string = encoder.pydantic_coverage.model_dump_json(exclude_none=True, indent=4) assert CoverageCollection.model_validate_json(json_string) + cov = encoder.covjson["coverages"][0] + assert set(cov["domain"]["axes"].keys()) == {"x", "y", "z", "t"} + assert cov["ranges"]["2t"]["axisNames"] == ["t"] + print(json_string) # @pytest.mark.data diff --git a/tests/test_xarray_ts.py b/tests/test_xarray_ts.py index 60ff3dc..ff177f9 100644 --- a/tests/test_xarray_ts.py +++ b/tests/test_xarray_ts.py @@ -44,3 +44,12 @@ def test_from_xarray(self): covjson_result["coverages"][0]["ranges"]["2t"]["values"][0] == self.test_covjson_xyz["coverages"][0]["ranges"]["2t"]["values"][0] ) + assert set(covjson_result["coverages"][0]["domain"]["axes"].keys()) == {"x", "y", "z", "t"} + assert covjson_result["referencing"] == [ + { + "coordinates": ["x", "y"], + "system": {"type": "GeographicCRS", "id": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"}, + }, + {"coordinates": ["t"], "system": {"type": "TemporalRS", "calendar": "Gregorian"}}, + {"coordinates": ["z"], "system": {"type": "VerticalCRS"}}, + ]