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..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()]
@@ -53,6 +51,29 @@ 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 from_xarray(self, datasets):
"""
Converts an xarray dataset or a list of xarray datasets into an OGC CoverageJSON
@@ -71,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)
@@ -115,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
@@ -143,6 +148,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 +161,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 +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(mm, coordinates[date][i], val_dict)
+ self.add_coverage(mm, coordinates[date][i], val_dict, include_z)
end = time.time()
delta = end - start
@@ -276,6 +275,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 +287,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 +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(mm, coord_entry, val_dict)
+ self.add_coverage(mm, coord_entry, val_dict, include_z)
end = time.time()
logging.debug("Coverage creation ends: %s", end)
@@ -381,6 +374,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 +387,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 +452,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(
+ 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..799dccc 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"
+}
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.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_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..ff177f9 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,35 @@ 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]
)
+ 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"}},
+ ]