diff --git a/covjsonkit/decoder/VerticalProfile.py b/covjsonkit/decoder/VerticalProfile.py index e417fe9..5ea934c 100644 --- a/covjsonkit/decoder/VerticalProfile.py +++ b/covjsonkit/decoder/VerticalProfile.py @@ -1,6 +1,6 @@ import xarray as xr -from ..encoder.encoder import sort_step_values, valid_time +from ..encoder.encoder import anoffset_timedelta, sort_step_values, valid_time from .decoder import Decoder @@ -168,7 +168,9 @@ def to_xarray(self): if len(param_values[parameter][domain_idx][i][j]) <= k: param_values[parameter][domain_idx][i][j].append([]) for coverage in self.covjson["coverages"]: - new_step = valid_time(date, step) + new_step = valid_time( + date, step, anoffset=anoffset_timedelta(coverage["mars:metadata"]) + ) if ( coverage["mars:metadata"]["number"] == num and self.forecast_reference(coverage) == date diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index 35fbc48..6817fb8 100644 --- a/covjsonkit/encoder/BoundingBox.py +++ b/covjsonkit/encoder/BoundingBox.py @@ -3,6 +3,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -126,7 +127,7 @@ def from_xarray(self, dataset): dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist() cov_coords = dict(coords) - cov_coords["t"] = [valid_time(datetime, step)] + cov_coords["t"] = [valid_time(datetime, step, anoffset=anoffset_timedelta(mars_metadata))] self.add_coverage(mars_metadata, cov_coords, dv_dict, include_z) # Return the generated CoverageJSON @@ -213,7 +214,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["step"] = normalize_step_value(step) # ``t`` is the valid-time (forecast date + step offset). cov_coords = dict(coords[date]) - cov_coords["t"] = [valid_time(date, step)] + cov_coords["t"] = [valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata))] if reanalysis: # Reanalysis (class=ce, stream=efcl): expose only the # valid-time; drop the scalar forecast-date metadata. diff --git a/covjsonkit/encoder/Circle.py b/covjsonkit/encoder/Circle.py index add05ef..55f0706 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -2,6 +2,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -117,7 +118,7 @@ def from_xarray(self, dataset): dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist() cov_coords = dict(coords) - cov_coords["t"] = [valid_time(datetime, step)] + cov_coords["t"] = [valid_time(datetime, step, anoffset=anoffset_timedelta(mars_metadata))] self.add_coverage(mars_metadata, cov_coords, dv_dict, include_z) return self.covjson @@ -200,7 +201,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["number"] = num mm["step"] = normalize_step_value(step) cov_coords = dict(coords[date]) - cov_coords["t"] = [valid_time(date, step)] + cov_coords["t"] = [valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata))] if reanalysis: mm.pop("Forecast date", None) mm.pop("step", None) diff --git a/covjsonkit/encoder/Frame.py b/covjsonkit/encoder/Frame.py index 4e2ecfd..388cf2d 100644 --- a/covjsonkit/encoder/Frame.py +++ b/covjsonkit/encoder/Frame.py @@ -2,6 +2,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -117,7 +118,7 @@ def from_xarray(self, dataset): dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist() cov_coords = dict(coords) - cov_coords["t"] = [valid_time(datetime, step)] + cov_coords["t"] = [valid_time(datetime, step, anoffset=anoffset_timedelta(mars_metadata))] self.add_coverage(mars_metadata, cov_coords, dv_dict, include_z) return self.covjson @@ -197,7 +198,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["number"] = num mm["step"] = normalize_step_value(step) cov_coords = dict(coords[date]) - cov_coords["t"] = [valid_time(date, step)] + cov_coords["t"] = [valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata))] if reanalysis: mm.pop("Forecast date", None) mm.pop("step", None) diff --git a/covjsonkit/encoder/Grid.py b/covjsonkit/encoder/Grid.py index 84a6168..fce0f72 100644 --- a/covjsonkit/encoder/Grid.py +++ b/covjsonkit/encoder/Grid.py @@ -1,7 +1,13 @@ import logging import time -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + anoffset_timedelta, + is_reanalysis, + normalize_step_value, + valid_time, +) class Grid(Encoder): @@ -123,7 +129,11 @@ def from_xarray(self, dataset): arr = dataset[dv].sel(datetimes=datetime, number=num, steps=step).values dv_dict[dv] = arr.ravel().tolist() - coords = {"t": [valid_time(datetime, step)], "y": y_vals, "x": x_vals} + coords = { + "t": [valid_time(datetime, step, anoffset=anoffset_timedelta(mars_metadata))], + "y": y_vals, + "x": x_vals, + } if include_z: coords["z"] = z_vals self.add_coverage(mars_metadata, coords, dv_dict, include_z) @@ -188,7 +198,9 @@ def from_polytope(self, result, date_key: str = "date") -> dict: for date in coords.keys(): coordinates[date] = {} # ``t`` axis holds the valid-time (forecast date + step) for each step. - coordinates[date]["t"] = [valid_time(date, s) for s in fields["step"]] + coordinates[date]["t"] = [ + valid_time(date, s, anoffset=anoffset_timedelta(mars_metadata)) for s in fields["step"] + ] if include_z: coordinates[date]["z"] = list(fields["levels"]) coordinates[date]["y"] = [] diff --git a/covjsonkit/encoder/Path.py b/covjsonkit/encoder/Path.py index b90b316..4c46e4c 100644 --- a/covjsonkit/encoder/Path.py +++ b/covjsonkit/encoder/Path.py @@ -2,6 +2,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -174,7 +175,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: # The composite ``t`` coordinate is the valid-time # (forecast date + step offset), as an ISO-8601 string. # The original ``s`` is kept for the range_dict lookups. - t_valid = valid_time(date, s) + t_valid = valid_time(date, s, anoffset=anoffset_timedelta(mars_metadata)) if include_z: if len(fields["levels"]) != 1: for lev in fields["levels"]: diff --git a/covjsonkit/encoder/Position.py b/covjsonkit/encoder/Position.py index 6505735..722d600 100644 --- a/covjsonkit/encoder/Position.py +++ b/covjsonkit/encoder/Position.py @@ -1,7 +1,7 @@ import logging import time -from .encoder import Encoder, is_reanalysis, range_shape, valid_time +from .encoder import Encoder, anoffset_timedelta, is_reanalysis, range_shape, valid_time class Position(Encoder): @@ -188,7 +188,9 @@ def from_polytope(self, result, date_key: str = "date") -> dict: for num in fields["number"]: for para in fields["param"]: for step in fields["step"]: - coordinates[date][i]["t"].append(valid_time(date, step)) + coordinates[date][i]["t"].append( + valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata)) + ) break break break diff --git a/covjsonkit/encoder/Shapefile.py b/covjsonkit/encoder/Shapefile.py index d06b1b1..ccaeceb 100644 --- a/covjsonkit/encoder/Shapefile.py +++ b/covjsonkit/encoder/Shapefile.py @@ -2,6 +2,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -117,7 +118,7 @@ def from_xarray(self, dataset): dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist() cov_coords = dict(coords) - cov_coords["t"] = [valid_time(datetime, step)] + cov_coords["t"] = [valid_time(datetime, step, anoffset=anoffset_timedelta(mars_metadata))] self.add_coverage(mars_metadata, cov_coords, dv_dict, include_z) return self.covjson @@ -197,7 +198,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["number"] = num mm["step"] = normalize_step_value(step) cov_coords = dict(coords[date]) - cov_coords["t"] = [valid_time(date, step)] + cov_coords["t"] = [valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata))] if reanalysis: mm.pop("Forecast date", None) mm.pop("step", None) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 0ec8553..5c89e9b 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -6,6 +6,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, iso_utc, range_shape, reference_datetime, @@ -273,7 +274,9 @@ def from_polytope(self, result, date_key: str = "date", reforecast: bool = False for num in fields["number"]: for para in fields["param"]: for step in fields["step"]: - coordinates[date][i]["t"].append(valid_time(date, step)) + coordinates[date][i]["t"].append( + valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata)) + ) break break break @@ -369,6 +372,7 @@ def from_polytope_reforecast(self, result) -> dict: coverages = {} coverage_order = [] param_order = [] + anoffsets = {} # Set True as soon as a forecast (efas) coverage is created, so the final # emission can be reordered date -> time -> point (see below). forecast_result = False @@ -412,7 +416,10 @@ def emit(full_path, flat_result): step = d.get("step", 0) hdate = d.get("hdate", d.get("date")) time_off = d.get("time") - stamp = valid_datetime(hdate, step, time_off) + if "anoffset" in d: + anoffsets["anoffset"] = self._single_anoffset(anoffsets, d["anoffset"]) + # ce/ef/gf: step is counted from the analysis reference, anoffset before the run. + stamp = valid_datetime(hdate, step, time_off, anoffset_timedelta(d)) # Stream determines the coverage grouping for class=ce: # * efcl (reforecast/reanalysis): collapse all hdates into a @@ -440,6 +447,8 @@ def emit(full_path, flat_result): if name in exclude_meta: continue meta[name] = stringify(d[name]) + if "anoffset" in meta: + meta["anoffset"] = anoffsets["anoffset"] meta["number"] = number meta["levelist"] = level # efas: Forecast date is the run (date + time); efcl: none; diff --git a/covjsonkit/encoder/VerticalProfile.py b/covjsonkit/encoder/VerticalProfile.py index fdb3cde..290ae82 100644 --- a/covjsonkit/encoder/VerticalProfile.py +++ b/covjsonkit/encoder/VerticalProfile.py @@ -3,6 +3,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -118,7 +119,10 @@ 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], - "t": [valid_time(x, step) for x in dataset["datetime"].values], + "t": [ + valid_time(x, step, anoffset=anoffset_timedelta(mars_metadata)) + for x in dataset["datetime"].values + ], }, dv_dict, ) @@ -173,7 +177,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: "latitude": [coords[date]["composite"][i][0]], "longitude": [coords[date]["composite"][i][1]], "levelist": list(levels), - "t": [valid_time(date, step)], + "t": [valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata))], } ) diff --git a/covjsonkit/encoder/Wkt.py b/covjsonkit/encoder/Wkt.py index 645647b..2053260 100644 --- a/covjsonkit/encoder/Wkt.py +++ b/covjsonkit/encoder/Wkt.py @@ -4,6 +4,7 @@ from .encoder import ( Encoder, + anoffset_timedelta, is_reanalysis, normalize_step_value, range_shape, @@ -119,7 +120,7 @@ def from_xarray(self, dataset): dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist() cov_coords = dict(coords) - cov_coords["t"] = [valid_time(datetime, step)] + cov_coords["t"] = [valid_time(datetime, step, anoffset=anoffset_timedelta(mars_metadata))] self.add_coverage(mars_metadata, cov_coords, dv_dict, include_z) return self.covjson @@ -202,7 +203,7 @@ def from_polytope(self, result, date_key: str = "date") -> dict: mm["number"] = num mm["step"] = normalize_step_value(step) cov_coords = dict(coords[date]) - cov_coords["t"] = [valid_time(date, step)] + cov_coords["t"] = [valid_time(date, step, anoffset=anoffset_timedelta(mars_metadata))] if reanalysis: mm.pop("Forecast date", None) mm.pop("step", None) diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index cf75764..48ebb65 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -177,9 +177,12 @@ def reference_datetime(date, time=None) -> pd.Timestamp: return to_naive_utc(date) + time_to_timedelta(time) -def valid_datetime(date, step=0, time=None) -> pd.Timestamp: - """Valid datetime: ``date + time + step`` (see :func:`step_to_timedelta`).""" - return reference_datetime(date, time) + step_to_timedelta(step) +def valid_datetime(date, step=0, time=None, anoffset=None) -> pd.Timestamp: + """Valid datetime: ``date + time + step - anoffset`` (see :func:`step_to_timedelta`). + + ``anoffset`` is a time difference in hours (see :func:`anoffset_timedelta`). + """ + return reference_datetime(date, time) + step_to_timedelta(step) - step_to_timedelta(anoffset) def sort_step_values(steps: list) -> list: @@ -280,14 +283,48 @@ def normalize_step_value(step): return str(step) -def valid_time(date, step=0, time=None) -> str: - """Return the ISO-8601 valid-time string ``date + time + step`` with a trailing ``"Z"``. +def valid_time(date, step=0, time=None, anoffset=None) -> str: + """Return the ISO-8601 valid-time string ``date + time + step - anoffset`` with a trailing ``"Z"``. ``date`` may be any value accepted by :func:`to_naive_utc`, ``time`` any - value accepted by :func:`time_to_timedelta` and ``step`` any value accepted - by :func:`step_to_timedelta`. + value accepted by :func:`time_to_timedelta`, and ``step`` and ``anoffset`` + any value accepted by :func:`step_to_timedelta`. + """ + return iso_utc(valid_datetime(date, step, time, anoffset)) + + +def anoffset_value(values): + """The single ``anoffset`` of a tree node or record, as integer hours when whole. + + MARS defines ``anoffset`` as an integer number of hours; polytope may hand it + over as a string. Only one ``anoffset`` per request is supported. """ - return iso_utc(valid_datetime(date, step, time)) + if isinstance(values, (list, tuple, np.ndarray)): + if len(values) != 1: + raise ValueError(f"Only a single anoffset value is supported, got {list(values)!r}") + values = values[0] + hours = step_to_timedelta(values).total_seconds() / 3600.0 + return int(hours) if hours.is_integer() else hours + + +#: MARS classes whose ``anoffset`` shifts the valid time (CEMS flood: EFAS and GloFAS), whatever the stream. +ANOFFSET_CLASSES = frozenset({"ce", "ef", "gf"}) + + +def anoffset_timedelta(mars_metadata) -> timedelta: + """The ``anoffset`` subtracted from valid times, for the :data:`ANOFFSET_CLASSES` only. + + ``anoffset`` shifts the analysis reference time back from the forecast run + (``date + time``) and ``step`` is counted from that shifted reference, so + ``valid = date + time + step - anoffset``. Other classes may carry an + ``anoffset`` but are not shifted; without one this is zero. + """ + if mars_metadata.get("class") not in ANOFFSET_CLASSES: + return timedelta(0) + value = mars_metadata.get("anoffset") + if value is None: + return timedelta(0) + return timedelta(hours=anoffset_value(value)) def is_reanalysis(mars_metadata: dict, date_key: str = "date") -> bool: @@ -492,6 +529,8 @@ def handle_non_leaf_node(child): elif child.axis.name == "step": # Step is not a timedelta! Need to normalize it val = normalize_step_value(val) + if child.axis.name == "anoffset": + val = self._single_anoffset(mars_metadata, child.values) mars_metadata[child.axis.name] = val def handle_specific_axes(child): @@ -655,6 +694,8 @@ def handle_non_leaf_node(child): elif child.axis.name == "step": # Step is not a timedelta! Need to normalize it val = normalize_step_value(val) + if child.axis.name == "anoffset": + val = self._single_anoffset(mars_metadata, child.values) mars_metadata[child.axis.name] = val def handle_specific_axes(child): @@ -773,6 +814,8 @@ def handle_non_leaf_node_step(child): elif child.axis.name == "step": # Step is not a timedelta! Need to normalize it val = normalize_step_value(val) + if child.axis.name == "anoffset": + val = self._single_anoffset(mars_metadata, child.values) mars_metadata[child.axis.name] = val def handle_specific_axes_step(child): @@ -913,6 +956,8 @@ def handle_non_leaf_node_month(child): val = child.values[0] if isinstance(val, np.datetime64): val = str(val) + if child.axis.name == "anoffset": + val = self._single_anoffset(mars_metadata, child.values) mars_metadata[child.axis.name] = val def handle_specific_axes_month(child): @@ -1101,6 +1146,16 @@ def _step_path_valid_time(self, date, time, fields) -> str: return valid_time(date, steps[0], time) return valid_time(date, 0, time) + @staticmethod + def _single_anoffset(mars_metadata, values): + """The request's ``anoffset`` for mars:metadata; only one value is supported.""" + value = anoffset_value(values) + if mars_metadata.get("anoffset", value) != value: + raise ValueError( + f"Only a single anoffset value is supported, got {mars_metadata['anoffset']!r} and {value!r}" + ) + return value + @staticmethod def _tree_has_axis(tree, axis_name) -> bool: stack = [tree] @@ -1163,7 +1218,8 @@ def _reforecast_coverages(self, result, by_step=True): ``+ time``. Each coverage maps ``(valid, lat, lon, level)`` entries, in record order, to ``{param: value}``. Coverages are returned in reference -> step -> number order with ``"Forecast date"`` applied by - :func:`set_forecast_date`. + :func:`set_forecast_date`. For the :data:`ANOFFSET_CLASSES` the valid time is shifted + back by the request's single ``anoffset`` (:func:`anoffset_timedelta`). Returns ``(coverages, param_order)``. """ @@ -1171,6 +1227,7 @@ def _reforecast_coverages(self, result, by_step=True): coverages = {} param_order = [] + anoffsets = {} for rec in self._reforecast_records(result): level = rec.get("levelist", 0) @@ -1186,7 +1243,9 @@ def _reforecast_coverages(self, result, by_step=True): para = rec.get("param") step = normalize_step_value(rec.get("step", 0)) ref = reference_datetime(rec.get("hdate", rec.get("date")), rec.get("time")) - valid = iso_utc(ref + step_to_timedelta(step)) + if "anoffset" in rec: + anoffsets["anoffset"] = self._single_anoffset(anoffsets, rec["anoffset"]) + valid = iso_utc(ref + step_to_timedelta(step) - anoffset_timedelta(rec)) key = (ref, step_to_timedelta(step), number) if by_step else (ref, number) if key not in coverages: @@ -1195,6 +1254,8 @@ def _reforecast_coverages(self, result, by_step=True): if name == "__value__" or name in exclude_meta: continue meta[name] = self._reforecast_stringify(rec[name]) + if "anoffset" in meta: + meta["anoffset"] = anoffset_value(rec["anoffset"]) meta["number"] = number if by_step: meta["step"] = step diff --git a/tests/conftest.py b/tests/conftest.py index 66ae065..a63bcb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -261,6 +261,10 @@ def forecast_separate_datetime_tree( step=(6, 24), param="240023", point_factory=make_point, + anoffset=None, + levels=None, + type_="cf", + cls="ce", ): """Build a separate-datetime *forecast* tree (class=ce, stream=efas). @@ -276,27 +280,36 @@ def forecast_separate_datetime_tree( here, so effectively (step, time)). times: tuple of ``np.timedelta64``/``timedelta`` time-of-day offsets. step: tuple of step values. + anoffset: optional ``anoffset`` axis values (hours), e.g. ``(18,)``. + levels: optional ``levelist`` values; the layout is then + (date, levelist, step, time). """ - branch = chain( + axes = [node("anoffset", anoffset)] if anoffset is not None else [] + axes += [ node("date", (date,)), node("domain", ("g",)), node("expver", ("8888",)), - node("levtype", ("sfc",)), + node("levtype", ("pl" if levels else "sfc",)), + ] + if levels: + axes.append(node("levelist", levels)) + axes += [ node("model", ("lisflood",)), node("origin", ("ecmf",)), node("param", (param,)), node("step", step), node("stream", ("efas",)), node("time", times), - node("type", ("cf",)), - ) + node("type", (type_,)), + ] + branch = chain(*axes) parent = tip(branch) for lat, lon, result in points: parent.add_child(point_factory(lat, lon, result)) tree = chain( TensorIndexTree(), - node("class", ("ce",)), + node("class", (cls,)), ) tip(tree).add_child(branch) return tree @@ -345,3 +358,40 @@ def reforecast_separate_datetime_vertical_tree( ) tip(tree).add_child(branch) return tree + + +def efas_tree( + points, + param="240023", + step=(6,), + date=np.datetime64("2026-01-01T00:00:00"), + anoffset=None, + cls="ce", + stream="efas", + type_="fc", + point_factory=make_point, +): + """Build a regular-forecast tree for the efas anoffset code path. + + Defaults to ``class=ce, stream=efas`` (the only combination that receives the + anoffset valid-time correction). Pass ``anoffset=`` to add the axis, + or override ``cls``/``stream`` to build a non-efas tree for gate tests. + """ + axes = [ + node("class", (cls,)), + node("date", (date,)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", (param,)), + node("step", step), + node("stream", (stream,)), + node("type", (type_,)), + ] + if anoffset is not None: + axes.append(node("anoffset", (anoffset,))) + tree = chain(TensorIndexTree(), *axes) + parent = tip(tree) + for lat, lon, result in points: + parent.add_child(point_factory(lat, lon, result)) + return tree diff --git a/tests/test_encoder_anoffset.py b/tests/test_encoder_anoffset.py new file mode 100644 index 0000000..04ef1bb --- /dev/null +++ b/tests/test_encoder_anoffset.py @@ -0,0 +1,139 @@ +"""anoffset on the separate-datetime path (``from_polytope_reforecast``) used for every class=ce request. + +For ``class`` in ``ce``/``ef``/``gf`` the valid time is ``date + time + step - anoffset`` while +``"Forecast date"`` stays the run (``date + time``); ``anoffset`` is kept in mars:metadata as +integer hours. Cases mirror the polytope-efas-configs anoffset fixtures. +""" + +import numpy as np +import pytest +from conftest import chain, forecast_separate_datetime_tree, make_point, node, tip +from polytope_feature.datacube.tensor_index_tree import TensorIndexTree + +from covjsonkit.api import Covjsonkit + +T00 = (np.timedelta64(0, "h"),) +T12 = (np.timedelta64(12, "h"),) +MULTIPOINT_FEATURES = ["boundingbox", "circle", "frame", "shapefile", "polygon"] + + +def _encode(feature, tree): + return Covjsonkit().encode("CoverageCollection", feature).from_polytope_reforecast(tree) + + +@pytest.mark.parametrize("anoffset", [18, "18"]) +def test_timeseries_anoffset_shifts_valid_time(anoffset): + # fu-ecmf: 2026-05-01 00:00 run, steps 6/12/18, anoffset 18h. + tree = forecast_separate_datetime_tree( + [(50.7, 7.1, [1.0, 2.0, 3.0])], T00, step=(6, 12, 18), anoffset=(anoffset,), type_="fu" + ) + cov = _encode("timeseries", tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == [ + "2026-04-30T12:00:00Z", + "2026-04-30T18:00:00Z", + "2026-05-01T00:00:00Z", + ] + assert cov["ranges"]["dis06"]["values"] == [1.0, 2.0, 3.0] + meta = cov["mars:metadata"] + assert meta["Forecast date"] == "2026-05-01T00:00:00Z" + assert meta["anoffset"] == 18 + assert "date" not in meta + + +def test_position_anoffset_shifts_valid_time(): + # fu-edzw: 12:00 run, step 6h, anoffset 30h -> previous day 12:00. + tree = forecast_separate_datetime_tree([(50.7, 7.1, [1.0])], T12, step=(6,), anoffset=(30,), type_="fu") + cov = _encode("position", tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-04-30T12:00:00Z"] + assert cov["mars:metadata"]["Forecast date"] == "2026-05-01T12:00:00Z" + assert cov["mars:metadata"]["anoffset"] == 30 + + +@pytest.mark.parametrize("feature", MULTIPOINT_FEATURES) +def test_multipoint_anoffset_shifts_valid_time(feature): + # sfo-ecmf rhombus: 00:00 run, steps 6/12, anoffset 90h. + points = [(50.7, 7.1, [1.0, 2.0]), (50.8, 7.2, [3.0, 4.0])] + tree = forecast_separate_datetime_tree(points, T00, step=(6, 12), anoffset=(90,), type_="sfo") + covjson = _encode(feature, tree) + seen = [ + (c["mars:metadata"]["Forecast date"], c["mars:metadata"]["step"], c["domain"]["axes"]["t"]["values"]) + for c in covjson["coverages"] + ] + assert seen == [ + ("2026-05-01T00:00:00Z", 6, ["2026-04-27T12:00:00Z"]), + ("2026-05-01T00:00:00Z", 12, ["2026-04-27T18:00:00Z"]), + ] + assert all(c["mars:metadata"]["anoffset"] == 90 for c in covjson["coverages"]) + + +def test_grid_anoffset_shifts_valid_time(): + points = [(50.7, 7.1, [1.0, 2.0]), (50.7, 7.2, [3.0, 4.0])] + tree = forecast_separate_datetime_tree(points, T00, step=(6, 12), anoffset=(18,), type_="fu") + cov = _encode("grid", tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-04-30T12:00:00Z", "2026-04-30T18:00:00Z"] + assert cov["mars:metadata"]["anoffset"] == 18 + + +def test_path_anoffset_shifts_valid_time(): + points = [(50.7, 7.1, [1.0]), (50.8, 7.2, [2.0])] + tree = forecast_separate_datetime_tree(points, T00, step=(6,), anoffset=(18,), type_="fu") + cov = _encode("path", tree)["coverages"][0] + assert [v[0] for v in cov["domain"]["axes"]["composite"]["values"]] == [ + "2026-04-30T12:00:00Z", + "2026-04-30T12:00:00Z", + ] + assert cov["mars:metadata"]["anoffset"] == 18 + + +def test_verticalprofile_anoffset_shifts_valid_time_and_round_trips(): + # Layout (date, levelist, step, time) with one step and time: [L500, L850]. + tree = forecast_separate_datetime_tree( + [(50.7, 7.1, [1.0, 2.0])], T12, step=(6,), anoffset=(30,), levels=(500, 850), param="130", type_="fu" + ) + covjson = _encode("verticalprofile", tree) + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-04-30T12:00:00Z"] + assert cov["domain"]["axes"]["z"]["values"] == [500, 850] + assert cov["ranges"]["t"]["values"] == [1.0, 2.0] + assert cov["mars:metadata"]["anoffset"] == 30 + + # The decoder rebuilds date + step - anoffset to place each coverage. + ds = Covjsonkit().decode(covjson).to_xarray() + assert ds["t"].values.ravel().tolist() == [1.0, 2.0] + + +def test_no_anoffset_leaves_valid_time_unchanged(): + tree = forecast_separate_datetime_tree([(50.7, 7.1, [1.0])], T00, step=(6,), type_="fu") + cov = _encode("timeseries", tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-05-01T06:00:00Z"] + assert "anoffset" not in cov["mars:metadata"] + + +@pytest.mark.parametrize("feature", ["timeseries", "position", "boundingbox", "grid", "path"]) +def test_multiple_anoffsets_rejected_on_reforecast_path(feature): + # Layout (anoffset, date, step, time): two anoffsets x one step. + tree = forecast_separate_datetime_tree([(50.7, 7.1, [1.0, 2.0])], T00, step=(6,), anoffset=(18, 42), type_="fu") + with pytest.raises(ValueError, match="single anoffset"): + _encode(feature, tree) + + +def test_multiple_anoffsets_rejected_on_from_polytope_path(): + tree = chain( + TensorIndexTree(), + node("anoffset", (18, 42)), + node("class", ("ce",)), + node("date", (np.datetime64("2026-05-01T00:00:00"),)), + node("param", ("240023",)), + node("step", (6,)), + node("stream", ("efas",)), + ) + tip(tree).add_child(make_point(50.7, 7.1, [1.0])) + with pytest.raises(ValueError, match="single anoffset"): + Covjsonkit().encode("CoverageCollection", "boundingbox").from_polytope(tree) + + +def test_other_flood_class_applies_anoffset(): + # class=gf (GloFAS) gets the same correction whatever its stream. + tree = forecast_separate_datetime_tree([(50.7, 7.1, [1.0])], T00, step=(6,), anoffset=(18,), type_="fu", cls="gf") + cov = _encode("timeseries", tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-04-30T12:00:00Z"] diff --git a/tests/test_encoder_bounding_box_from_polytope.py b/tests/test_encoder_bounding_box_from_polytope.py index ef207f5..9383282 100644 --- a/tests/test_encoder_bounding_box_from_polytope.py +++ b/tests/test_encoder_bounding_box_from_polytope.py @@ -1,6 +1,7 @@ import numpy as np from conftest import ( chain, + efas_tree, forecast_tree, make_point, node, @@ -200,3 +201,25 @@ def test_reforecast_single_hdate_two_steps_two_points(self): } assert cov["ranges"]["2t"]["values"] == vals assert cov["mars:metadata"] == EXPECTED_REFORECAST_METADATA + + +class TestBoundingBoxEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "BoundingBox").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_circle_from_polytope.py b/tests/test_encoder_circle_from_polytope.py index f6fbb5a..a31a2cc 100644 --- a/tests/test_encoder_circle_from_polytope.py +++ b/tests/test_encoder_circle_from_polytope.py @@ -1,6 +1,7 @@ import numpy as np from conftest import ( REFORECAST_METADATA_BASE, + efas_tree, forecast_tree, reforecast_branch, reforecast_tree, @@ -112,3 +113,25 @@ def test_reforecast_two_hdates_three_points(self): } assert cov["ranges"]["2t"]["values"] == range_vals assert cov["mars:metadata"] == REFORECAST_METADATA_BASE + + +class TestCircleEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Circle").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_frame_from_polytope.py b/tests/test_encoder_frame_from_polytope.py index 2d7810f..eb73e2f 100644 --- a/tests/test_encoder_frame_from_polytope.py +++ b/tests/test_encoder_frame_from_polytope.py @@ -2,6 +2,7 @@ from conftest import ( COMPOSITE_TWO_POINTS, REFORECAST_METADATA_BASE, + efas_tree, forecast_tree, reforecast_branch, reforecast_tree, @@ -100,3 +101,25 @@ def test_reforecast_two_hdates_two_points(self): } assert cov["ranges"]["2t"]["values"] == vals assert cov["mars:metadata"] == REFORECAST_METADATA_BASE + + +class TestFrameEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Frame").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_grid_from_polytope.py b/tests/test_encoder_grid_from_polytope.py index 0eaa1d1..0be826e 100644 --- a/tests/test_encoder_grid_from_polytope.py +++ b/tests/test_encoder_grid_from_polytope.py @@ -2,6 +2,7 @@ from conftest import ( REFORECAST_METADATA_BASE, chain, + efas_tree, make_point, node, reforecast_branch, @@ -187,3 +188,25 @@ def test_reforecast_two_hdates_2x2_grid(self): assert cov["domain"]["axes"] == grid_2x2_axes(fc_date) assert cov["ranges"] == GRID_2X2_RANGES assert cov["mars:metadata"] == REFORECAST_METADATA_BASE + + +class TestGridEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Grid").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_helpers.py b/tests/test_encoder_helpers.py new file mode 100644 index 0000000..176388c --- /dev/null +++ b/tests/test_encoder_helpers.py @@ -0,0 +1,87 @@ +"""Unit tests for the shared valid-time / anoffset helpers in encoder.py.""" + +from datetime import timedelta + +import numpy as np +import pytest + +from covjsonkit.encoder.encoder import anoffset_timedelta, anoffset_value, valid_time + + +class TestValidTime: + def test_integer_step(self): + assert valid_time("20250101T000000", 6) == "2025-01-01T06:00:00Z" + + def test_z_suffixed_date(self): + assert valid_time("2025-01-01T00:00:00Z", 6) == "2025-01-01T06:00:00Z" + + def test_string_step_with_hour_suffix(self): + assert valid_time("20250101T000000", "6h") == "2025-01-01T06:00:00Z" + + def test_timedelta_step(self): + assert valid_time("20250101T000000", np.timedelta64(6, "h")) == "2025-01-01T06:00:00Z" + + def test_single_element_list_step(self): + assert valid_time("20250101T000000", [6]) == "2025-01-01T06:00:00Z" + + def test_anoffset_subtracted(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + assert valid_time("20250101T000000", 6, anoffset=6) == "2025-01-01T00:00:00Z" + + def test_anoffset_partial(self): + # 00:00 + step 6h - anoffset 3h = 03:00 + assert valid_time("20250101T000000", 6, anoffset=3) == "2025-01-01T03:00:00Z" + + def test_anoffset_zero_is_noop(self): + assert valid_time("20250101T000000", 6, anoffset=0) == "2025-01-01T06:00:00Z" + + def test_anoffset_with_time(self): + # 12:00 run + step 6h - anoffset 30h = previous day 12:00 (fu-edzw fixture) + assert valid_time("2026-05-01", 6, np.timedelta64(12, "h"), anoffset=30) == "2026-04-30T12:00:00Z" + + +class TestAnoffsetValue: + @pytest.mark.parametrize("value", [6, "6", "6h", [6], ("6",), np.int64(6), timedelta(hours=6)]) + def test_integer_hours(self, value): + assert anoffset_value(value) == 6 + + def test_multiple_values_rejected(self): + with pytest.raises(ValueError, match="single anoffset"): + anoffset_value([6, 18]) + + def test_unparseable_rejected(self): + with pytest.raises(ValueError): + anoffset_value("garbage") + + +class TestAnoffsetTimedelta: + def test_efas_with_anoffset(self): + mm = {"class": "ce", "stream": "efas", "anoffset": 6} + assert anoffset_timedelta(mm) == timedelta(hours=6) + + def test_efas_string_anoffset(self): + mm = {"class": "ce", "stream": "efas", "anoffset": "90"} + assert anoffset_timedelta(mm) == timedelta(hours=90) + + def test_efas_without_anoffset(self): + mm = {"class": "ce", "stream": "efas"} + assert anoffset_timedelta(mm) == timedelta(0) + + def test_none_is_zero(self): + mm = {"class": "ce", "stream": "efas", "anoffset": None} + assert anoffset_timedelta(mm) == timedelta(0) + + @pytest.mark.parametrize("cls", ["ce", "ef", "gf"]) + @pytest.mark.parametrize("stream", ["efas", "efcl", "oper"]) + def test_flood_classes_apply_whatever_the_stream(self, cls, stream): + mm = {"class": cls, "stream": stream, "anoffset": 6} + assert anoffset_timedelta(mm) == timedelta(hours=6) + + def test_other_class_ignored(self): + # anoffset present but class is not ce/ef/gf -> not applied. + mm = {"class": "od", "stream": "oper", "anoffset": 6} + assert anoffset_timedelta(mm) == timedelta(0) + + def test_other_class_with_efas_stream_ignored(self): + mm = {"class": "od", "stream": "efas", "anoffset": 6} + assert anoffset_timedelta(mm) == timedelta(0) diff --git a/tests/test_encoder_path_from_polytope.py b/tests/test_encoder_path_from_polytope.py index e194bab..8b0f10e 100644 --- a/tests/test_encoder_path_from_polytope.py +++ b/tests/test_encoder_path_from_polytope.py @@ -1,6 +1,7 @@ import numpy as np from conftest import ( REFORECAST_METADATA_BASE, + efas_tree, forecast_tree, reforecast_branch, reforecast_tree, @@ -142,3 +143,31 @@ def test_reforecast_two_hdates_two_points(self): assert cov["domain"]["axes"] == self.axes_for(fc_date) assert cov["ranges"] == self.EXPECTED_RANGES assert cov["mars:metadata"] == REFORECAST_METADATA_BASE + + +class TestPathEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast). + + Path embeds the valid-time as the first element of each composite tuple. + """ + + def _t_values(self, cov): + return [v[0] for v in cov["domain"]["axes"]["composite"]["values"]] + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Path").from_polytope(tree)["coverages"][0] + assert all(t == "2026-01-01T00:00:00Z" for t in self._t_values(cov)) + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Path").from_polytope(tree)["coverages"][0] + assert all(t == "2026-01-01T06:00:00Z" for t in self._t_values(cov)) + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Path").from_polytope(tree)["coverages"][0] + assert all(t == "2026-01-01T06:00:00Z" for t in self._t_values(cov)) diff --git a/tests/test_encoder_position_from_polytope.py b/tests/test_encoder_position_from_polytope.py index 1fcf9a4..f4f9ba4 100644 --- a/tests/test_encoder_position_from_polytope.py +++ b/tests/test_encoder_position_from_polytope.py @@ -1,6 +1,7 @@ import numpy as np from conftest import ( assert_valid_covjson, + efas_tree, forecast_tree, reforecast_branch, reforecast_tree, @@ -212,3 +213,25 @@ def test_reforecast_two_hdates_two_points(self): } assert cov["ranges"]["2t"]["values"] == vals assert cov["mars:metadata"] == shared_metadata + + +class TestPositionEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Position").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_shapefile_from_polytope.py b/tests/test_encoder_shapefile_from_polytope.py index 5eb75bd..9e9cecc 100644 --- a/tests/test_encoder_shapefile_from_polytope.py +++ b/tests/test_encoder_shapefile_from_polytope.py @@ -2,6 +2,7 @@ from conftest import ( COMPOSITE_TWO_POINTS, REFORECAST_METADATA_BASE, + efas_tree, forecast_tree, reforecast_branch, reforecast_tree, @@ -100,3 +101,25 @@ def test_reforecast_two_hdates_two_points(self): } assert cov["ranges"]["2t"]["values"] == range_vals assert cov["mars:metadata"] == REFORECAST_METADATA_BASE + + +class TestShapefileEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Shapefile").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 4d659e2..0cb0558 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -170,6 +170,84 @@ def test_standard_forecast_multiple_coverages(self): assert cov["ranges"]["dis06"]["values"] == vals assert cov["mars:metadata"] == {**shared_metadata, "Forecast date": date} + def test_efas_anoffset_shifts_valid_time(self): + # class=ce, stream=efas regular forecast with anoffset present: + # valid_datetime = forecast_issue_datetime + step - anoffset. + # Here 00:00 + step 6h - anoffset 6h = 00:00. + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2026-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("model", ("lisflood",)), + node("origin", ("ecmf",)), + node("param", ("240023",)), + node("step", (6,)), + node("stream", ("efas",)), + node("type", ("fc",)), + node("anoffset", (6,)), + make_point(51.5, 6.5, [12.5]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + assert cov["ranges"]["dis06"]["values"] == [12.5] + # anoffset is preserved in the metadata. + assert cov["mars:metadata"]["anoffset"] == 6 + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset in an efas request → valid-time is plain date + step + # (00:00 + 6h = 06:00), defaulting anoffset to 0h. + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2026-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("model", ("lisflood",)), + node("origin", ("ecmf",)), + node("param", ("240023",)), + node("step", (6,)), + node("stream", ("efas",)), + node("type", ("fc",)), + make_point(51.5, 6.5, [12.5]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + assert "anoffset" not in cov["mars:metadata"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset can appear on other streams, but the correction is restricted + # to stream=efas. Here class=od, stream=oper with anoffset=6 must NOT be + # shifted: valid-time stays date + step (00:00 + 6h = 06:00). + tree = chain( + TensorIndexTree(), + node("class", ("od",)), + node("date", (np.datetime64("2026-01-01T00:00:00"),)), + node("domain", ("g",)), + node("expver", ("0001",)), + node("levtype", ("sfc",)), + node("param", ("167",)), + node("step", (6,)), + node("stream", ("oper",)), + node("type", ("fc",)), + node("anoffset", (6,)), + make_point(51.5, 6.5, [264.9]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope(tree) + + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + def test_multiple_params(self): # 1 date, 2 params (167 = 2t, 168 = 2d), 1 step, 1 point → 1 coverage with both params tree = chain( diff --git a/tests/test_encoder_vertical_profile_from_polytope.py b/tests/test_encoder_vertical_profile_from_polytope.py index c6b11af..3fade91 100644 --- a/tests/test_encoder_vertical_profile_from_polytope.py +++ b/tests/test_encoder_vertical_profile_from_polytope.py @@ -1,5 +1,13 @@ import numpy as np -from conftest import assert_valid_covjson, chain, make_leaf, make_point, node, tip +from conftest import ( + assert_valid_covjson, + chain, + efas_tree, + make_leaf, + make_point, + node, + tip, +) from polytope_feature.datacube.tensor_index_tree import TensorIndexTree from covjsonkit.api import Covjsonkit @@ -315,3 +323,25 @@ def test_reforecast_two_hdates_three_levels(self): } } assert cov["mars:metadata"] == self.EXPECTED_REFORECAST_METADATA + + +class TestVerticalProfileEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "VerticalProfile").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] diff --git a/tests/test_encoder_wkt_from_polytope.py b/tests/test_encoder_wkt_from_polytope.py index f0db244..f8f7e29 100644 --- a/tests/test_encoder_wkt_from_polytope.py +++ b/tests/test_encoder_wkt_from_polytope.py @@ -2,6 +2,7 @@ from conftest import ( COMPOSITE_TWO_POINTS, REFORECAST_METADATA_BASE, + efas_tree, forecast_tree, reforecast_branch, reforecast_tree, @@ -100,3 +101,25 @@ def test_reforecast_two_hdates_two_points(self): } assert cov["ranges"]["2t"]["values"] == range_vals assert cov["mars:metadata"] == REFORECAST_METADATA_BASE + + +class TestWktEfasAnoffset: + """anoffset valid-time correction for class=ce, stream=efas (regular forecast).""" + + def test_efas_anoffset_shifts_valid_time(self): + # 00:00 + step 6h - anoffset 6h = 00:00 + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6) + cov = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T00:00:00Z"] + + def test_efas_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset -> 00:00 + step 6h = 06:00 + tree = efas_tree([(48.0, 11.0, [12.5])]) + cov = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"] + + def test_anoffset_ignored_for_non_efas_stream(self): + # anoffset present but stream != efas -> valid-time not shifted (06:00) + tree = efas_tree([(48.0, 11.0, [12.5])], anoffset=6, cls="od", stream="oper") + cov = Covjsonkit().encode("CoverageCollection", "Polygon").from_polytope(tree)["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2026-01-01T06:00:00Z"]