From ed966363649edf21c97f9e64f3ea388bb363f88f Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 15 Sep 2026 10:35:39 +0100 Subject: [PATCH 1/6] Use anoffset to compute validity time when in request. Only use anoffset in stream efcl --- covjsonkit/encoder/TimeSeries.py | 52 ++++++++++++--- .../test_encoder_time_series_from_polytope.py | 63 +++++++++++++++++++ 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 6cd8456..31b0606 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -75,22 +75,51 @@ def _set_references(self, include_z): self.covjson["referencing"] = refs @staticmethod - def _hdate_step_timestamp(date, step): + def _anoffset_hours(mars_metadata): + """Return the ``anoffset`` (in hours) from ``mars_metadata`` as an int. + + ``anoffset`` shifts the analysis reference time *backward* from the + forecast cycle (``date``/``time``). ``step`` is counted from that shifted + reference time, so the valid-time must be corrected by subtracting + ``anoffset`` hours (see issue #126). Returns ``0`` when ``anoffset`` is + absent (the common case), leaving the timestamp unchanged. + """ + raw = mars_metadata.get("anoffset") + if raw is None: + return 0 + if isinstance(raw, (list, tuple)): + raw = raw[0] + try: + return int(raw) + except (TypeError, ValueError): + try: + return int(str(raw).split("h")[0]) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _hdate_step_timestamp(date, step, anoffset_hours=0): """Return the valid-time (as a datetime) for a given hdate and step. Mirrors the per-hdate stamp computation used in ``from_polytope`` so the - collapsed reanalysis path produces identical timestamps. + collapsed reanalysis path produces identical timestamps. When + ``anoffset_hours`` is non-zero the reference time is shifted backward by + that many hours before adding ``step`` (issue #126). """ date_format = "%Y%m%dT%H%M%S" new_date = pd.Timestamp(date).strftime(date_format) start_time = datetime.strptime(new_date, date_format) if isinstance(step, timedelta): - return start_time + step - try: - int(step) - except ValueError: - step = step[0] - return start_time + timedelta(hours=int(step)) + stamp = start_time + step + else: + try: + int(step) + except ValueError: + step = step[0] + stamp = start_time + timedelta(hours=int(step)) + if anoffset_hours: + stamp -= timedelta(hours=anoffset_hours) + return stamp def _collapse_reanalysis(self, fields, coords, mars_metadata, range_dict): """Collapse all hdates for each point into a single PointSeries coverage. @@ -108,11 +137,16 @@ def _collapse_reanalysis(self, fields, coords, mars_metadata, range_dict): points = len(coords[fields["dates"][0]]["composite"]) first_date = fields["dates"][0] + # ``anoffset`` (if present) shifts the reference time backward; step is + # counted from the shifted reference, so subtract it from every valid-time + # (issue #126). Confined to this efcl reanalysis path. + anoffset_hours = self._anoffset_hours(mars_metadata) + # Ordered list of (stamp, date, step) across every hdate/step combination. stamp_order = [] for date in fields["dates"]: for step in fields["step"]: - stamp = self._hdate_step_timestamp(date, step) + stamp = self._hdate_step_timestamp(date, step, anoffset_hours) stamp_order.append((stamp, date, step)) # Stable sort by valid-time so ties preserve insertion order. stamp_order.sort(key=lambda x: x[0]) diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 5d8c8be..344cf5d 100644 --- a/tests/test_encoder_time_series_from_polytope.py +++ b/tests/test_encoder_time_series_from_polytope.py @@ -318,6 +318,69 @@ def test_multiple_hdates(self): assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] assert cov["mars:metadata"] == EXPECTED_HDATE_METADATA + def test_anoffset_shifts_valid_time(self): + # anoffset shifts the reference time backward, so the valid-time is + # (hdate - anoffset) + step. Here hdate 06:00 + step 6h = 12:00, but with + # anoffset=6 the corrected valid-time is (06:00 - 6h) + 6h = 06:00. (issue #126) + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + node("anoffset", (6,)), + *[node(n, v) for n, v in HDATE_SUFFIX], + make_point(51.5, 6.5, [42.17]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + assert len(covjson["coverages"]) == 1 + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] + assert cov["ranges"]["dis06"]["values"] == [42.17] + # anoffset is preserved in the metadata. + assert cov["mars:metadata"] == {**EXPECTED_HDATE_METADATA, "anoffset": 6} + + def test_anoffset_absent_leaves_valid_time_unchanged(self): + # No anoffset → valid-time is the plain hdate + step (06:00 + 6h = 12:00). + tree = chain( + TensorIndexTree(), + node("class", ("ce",)), + node("date", (np.datetime64("2024-03-01"),)), + node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), + *[node(n, v) for n, v in HDATE_SUFFIX], + make_point(51.5, 6.5, [42.17]), + ) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + cov = covjson["coverages"][0] + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] + assert "anoffset" not in cov["mars:metadata"] + + def test_anoffset_applied_across_multiple_hdates(self): + # anoffset=3 subtracted from every valid-time across two hdates. + tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) + date = tip(tree) + for hdate_val, val in [ + (np.datetime64("2025-07-14T06:00:00"), [42.17]), + (np.datetime64("2025-07-15T06:00:00"), [55.30]), + ]: + branch = chain( + node("hdate", (hdate_val,)), + node("anoffset", (3,)), + *[node(n, v) for n, v in HDATE_SUFFIX], + make_point(51.5, 6.5, val), + ) + date.add_child(branch) + + covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) + + cov = covjson["coverages"][0] + # each: (hdate 06:00 - 3h) + step 6h = 09:00 + assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T09:00:00Z", "2025-07-15T09:00:00Z"] + assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] + def test_two_points(self): # 1 hdate, 2 points → 2 coverages (one per point) tree = chain( From dafdd6065d2bc689e80fde66075ffe45b3975e1f Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 15 Sep 2026 11:43:58 +0100 Subject: [PATCH 2/6] Chnage anoffset calculation to be triggered on stream efas not efcl --- covjsonkit/encoder/TimeSeries.py | 35 +++-- .../test_encoder_time_series_from_polytope.py | 141 ++++++++++-------- 2 files changed, 97 insertions(+), 79 deletions(-) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index 31b0606..4e2c3ab 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -79,10 +79,10 @@ def _anoffset_hours(mars_metadata): """Return the ``anoffset`` (in hours) from ``mars_metadata`` as an int. ``anoffset`` shifts the analysis reference time *backward* from the - forecast cycle (``date``/``time``). ``step`` is counted from that shifted - reference time, so the valid-time must be corrected by subtracting - ``anoffset`` hours (see issue #126). Returns ``0`` when ``anoffset`` is - absent (the common case), leaving the timestamp unchanged. + forecast issue time (``date``/``time``). ``step`` is counted from that + shifted reference, so the valid-time must be corrected by subtracting + ``anoffset`` hours. Returns ``0`` when ``anoffset`` is absent (the common + case), leaving the timestamp unchanged. """ raw = mars_metadata.get("anoffset") if raw is None: @@ -98,13 +98,11 @@ def _anoffset_hours(mars_metadata): return 0 @staticmethod - def _hdate_step_timestamp(date, step, anoffset_hours=0): + def _hdate_step_timestamp(date, step): """Return the valid-time (as a datetime) for a given hdate and step. Mirrors the per-hdate stamp computation used in ``from_polytope`` so the - collapsed reanalysis path produces identical timestamps. When - ``anoffset_hours`` is non-zero the reference time is shifted backward by - that many hours before adding ``step`` (issue #126). + collapsed reanalysis path produces identical timestamps. """ date_format = "%Y%m%dT%H%M%S" new_date = pd.Timestamp(date).strftime(date_format) @@ -117,8 +115,6 @@ def _hdate_step_timestamp(date, step, anoffset_hours=0): except ValueError: step = step[0] stamp = start_time + timedelta(hours=int(step)) - if anoffset_hours: - stamp -= timedelta(hours=anoffset_hours) return stamp def _collapse_reanalysis(self, fields, coords, mars_metadata, range_dict): @@ -137,16 +133,11 @@ def _collapse_reanalysis(self, fields, coords, mars_metadata, range_dict): points = len(coords[fields["dates"][0]]["composite"]) first_date = fields["dates"][0] - # ``anoffset`` (if present) shifts the reference time backward; step is - # counted from the shifted reference, so subtract it from every valid-time - # (issue #126). Confined to this efcl reanalysis path. - anoffset_hours = self._anoffset_hours(mars_metadata) - # Ordered list of (stamp, date, step) across every hdate/step combination. stamp_order = [] for date in fields["dates"]: for step in fields["step"]: - stamp = self._hdate_step_timestamp(date, step, anoffset_hours) + stamp = self._hdate_step_timestamp(date, step) stamp_order.append((stamp, date, step)) # Stable sort by valid-time so ties preserve insertion order. stamp_order.sort(key=lambda x: x[0]) @@ -293,6 +284,16 @@ def from_polytope(self, result, date_key: str = "date") -> dict: points = len(coords[fields["dates"][0]]["composite"]) + # Regular forecast valid-time correction for ``class=ce, stream=efas``: + # valid_datetime = forecast_issue_datetime + step - anoffset. Most efas + # types have no anoffset in the request, so this defaults to 0h (no-op). + # Restricted to efas; all other streams are unaffected. + efas_anoffset = ( + self._anoffset_hours(mars_metadata) + if mars_metadata.get("class") == "ce" and mars_metadata.get("stream") == "efas" + else 0 + ) + for date in fields["dates"]: coordinates[date] = [] for i, point in enumerate(range(points)): @@ -320,6 +321,8 @@ def from_polytope(self, result, date_key: str = "date") -> dict: except ValueError: step = step[0] stamp = start_time + timedelta(hours=int(step)) + if efas_anoffset: + stamp -= timedelta(hours=efas_anoffset) coordinates[date][i]["t"].append(stamp.isoformat() + "Z") break break diff --git a/tests/test_encoder_time_series_from_polytope.py b/tests/test_encoder_time_series_from_polytope.py index 344cf5d..0f20f34 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( @@ -318,69 +396,6 @@ def test_multiple_hdates(self): assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] assert cov["mars:metadata"] == EXPECTED_HDATE_METADATA - def test_anoffset_shifts_valid_time(self): - # anoffset shifts the reference time backward, so the valid-time is - # (hdate - anoffset) + step. Here hdate 06:00 + step 6h = 12:00, but with - # anoffset=6 the corrected valid-time is (06:00 - 6h) + 6h = 06:00. (issue #126) - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), - node("anoffset", (6,)), - *[node(n, v) for n, v in HDATE_SUFFIX], - make_point(51.5, 6.5, [42.17]), - ) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - assert len(covjson["coverages"]) == 1 - cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T06:00:00Z"] - assert cov["ranges"]["dis06"]["values"] == [42.17] - # anoffset is preserved in the metadata. - assert cov["mars:metadata"] == {**EXPECTED_HDATE_METADATA, "anoffset": 6} - - def test_anoffset_absent_leaves_valid_time_unchanged(self): - # No anoffset → valid-time is the plain hdate + step (06:00 + 6h = 12:00). - tree = chain( - TensorIndexTree(), - node("class", ("ce",)), - node("date", (np.datetime64("2024-03-01"),)), - node("hdate", (np.datetime64("2025-07-14T06:00:00"),)), - *[node(n, v) for n, v in HDATE_SUFFIX], - make_point(51.5, 6.5, [42.17]), - ) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - cov = covjson["coverages"][0] - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T12:00:00Z"] - assert "anoffset" not in cov["mars:metadata"] - - def test_anoffset_applied_across_multiple_hdates(self): - # anoffset=3 subtracted from every valid-time across two hdates. - tree = chain(TensorIndexTree(), node("class", ("ce",)), node("date", (np.datetime64("2024-03-01"),))) - date = tip(tree) - for hdate_val, val in [ - (np.datetime64("2025-07-14T06:00:00"), [42.17]), - (np.datetime64("2025-07-15T06:00:00"), [55.30]), - ]: - branch = chain( - node("hdate", (hdate_val,)), - node("anoffset", (3,)), - *[node(n, v) for n, v in HDATE_SUFFIX], - make_point(51.5, 6.5, val), - ) - date.add_child(branch) - - covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree) - - cov = covjson["coverages"][0] - # each: (hdate 06:00 - 3h) + step 6h = 09:00 - assert cov["domain"]["axes"]["t"]["values"] == ["2025-07-14T09:00:00Z", "2025-07-15T09:00:00Z"] - assert cov["ranges"]["dis06"]["values"] == [42.17, 55.30] - def test_two_points(self): # 1 hdate, 2 points → 2 coverages (one per point) tree = chain( From 8f09ee378aa02437a4d659175ad3dbdb4981fde7 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Wed, 16 Sep 2026 15:05:49 +0100 Subject: [PATCH 3/6] Generalise valid time calcualtion using anoffset for all features. Confined to stream=efas --- covjsonkit/encoder/BoundingBox.py | 10 ++++- covjsonkit/encoder/Circle.py | 10 ++++- covjsonkit/encoder/Frame.py | 10 ++++- covjsonkit/encoder/Grid.py | 11 +++++- covjsonkit/encoder/Path.py | 10 ++++- covjsonkit/encoder/Position.py | 22 ++++------- covjsonkit/encoder/Shapefile.py | 10 ++++- covjsonkit/encoder/TimeSeries.py | 51 +++----------------------- covjsonkit/encoder/VerticalProfile.py | 30 +++++++-------- covjsonkit/encoder/Wkt.py | 10 ++++- covjsonkit/encoder/encoder.py | 53 ++++++++++++++++++++++++--- 11 files changed, 130 insertions(+), 97 deletions(-) diff --git a/covjsonkit/encoder/BoundingBox.py b/covjsonkit/encoder/BoundingBox.py index 4381c9b..3ff03c7 100644 --- a/covjsonkit/encoder/BoundingBox.py +++ b/covjsonkit/encoder/BoundingBox.py @@ -1,7 +1,13 @@ import logging import time -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class BoundingBox(Encoder): @@ -204,7 +210,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, efas_anoffset_hours(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 eee67f2..1efe31f 100644 --- a/covjsonkit/encoder/Circle.py +++ b/covjsonkit/encoder/Circle.py @@ -1,6 +1,12 @@ import logging -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class Circle(Encoder): @@ -191,7 +197,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, efas_anoffset_hours(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 67628ac..21b2783 100644 --- a/covjsonkit/encoder/Frame.py +++ b/covjsonkit/encoder/Frame.py @@ -1,6 +1,12 @@ import logging -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class Frame(Encoder): @@ -188,7 +194,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, efas_anoffset_hours(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 b368434..400c48d 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, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class Grid(Encoder): @@ -188,7 +194,8 @@ 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"]] + _ao = efas_anoffset_hours(mars_metadata) + coordinates[date]["t"] = [valid_time(date, s, _ao) 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 a42c9bc..2e44db6 100644 --- a/covjsonkit/encoder/Path.py +++ b/covjsonkit/encoder/Path.py @@ -1,6 +1,12 @@ import logging -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class Path(Encoder): @@ -167,7 +173,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, efas_anoffset_hours(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 371aab2..44373ae 100644 --- a/covjsonkit/encoder/Position.py +++ b/covjsonkit/encoder/Position.py @@ -1,10 +1,9 @@ import logging import time -from datetime import datetime, timedelta import pandas as pd -from .encoder import Encoder, is_reanalysis +from .encoder import Encoder, efas_anoffset_hours, is_reanalysis, valid_time class Position(Encoder): @@ -176,6 +175,11 @@ def from_polytope(self, result, date_key: str = "date") -> dict: points = len(coords[fields["dates"][0]]["composite"]) + # Regular forecast valid-time correction for ``class=ce, stream=efas``: + # valid_datetime = forecast_issue_datetime + step - anoffset (0h no-op + # otherwise, and restricted to efas so other streams are unaffected). + efas_anoffset = efas_anoffset_hours(mars_metadata) + for date in fields["dates"]: coordinates[date] = [] for i, point in enumerate(range(points)): @@ -191,19 +195,7 @@ 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"]: - date_format = "%Y%m%dT%H%M%S" - new_date = pd.Timestamp(date).strftime(date_format) - start_time = datetime.strptime(new_date, date_format) - # add current date to list by converting it to iso format - if isinstance(step, timedelta): - stamp = start_time + step - else: - try: - int(step) - except ValueError: - step = step[0] - stamp = start_time + timedelta(hours=int(step)) - coordinates[date][i]["t"].append(stamp.isoformat() + "Z") + coordinates[date][i]["t"].append(valid_time(date, step, efas_anoffset)) break break break diff --git a/covjsonkit/encoder/Shapefile.py b/covjsonkit/encoder/Shapefile.py index 6be6dbe..3124a03 100644 --- a/covjsonkit/encoder/Shapefile.py +++ b/covjsonkit/encoder/Shapefile.py @@ -1,6 +1,12 @@ import logging -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class Shapefile(Encoder): @@ -188,7 +194,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, efas_anoffset_hours(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 4e2c3ab..ec2f528 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -4,7 +4,7 @@ import pandas as pd -from .encoder import Encoder +from .encoder import Encoder, efas_anoffset_hours, valid_time class TimeSeries(Encoder): @@ -74,29 +74,6 @@ def _set_references(self, include_z): ) self.covjson["referencing"] = refs - @staticmethod - def _anoffset_hours(mars_metadata): - """Return the ``anoffset`` (in hours) from ``mars_metadata`` as an int. - - ``anoffset`` shifts the analysis reference time *backward* from the - forecast issue time (``date``/``time``). ``step`` is counted from that - shifted reference, so the valid-time must be corrected by subtracting - ``anoffset`` hours. Returns ``0`` when ``anoffset`` is absent (the common - case), leaving the timestamp unchanged. - """ - raw = mars_metadata.get("anoffset") - if raw is None: - return 0 - if isinstance(raw, (list, tuple)): - raw = raw[0] - try: - return int(raw) - except (TypeError, ValueError): - try: - return int(str(raw).split("h")[0]) - except (TypeError, ValueError): - return 0 - @staticmethod def _hdate_step_timestamp(date, step): """Return the valid-time (as a datetime) for a given hdate and step. @@ -286,13 +263,9 @@ def from_polytope(self, result, date_key: str = "date") -> dict: # Regular forecast valid-time correction for ``class=ce, stream=efas``: # valid_datetime = forecast_issue_datetime + step - anoffset. Most efas - # types have no anoffset in the request, so this defaults to 0h (no-op). - # Restricted to efas; all other streams are unaffected. - efas_anoffset = ( - self._anoffset_hours(mars_metadata) - if mars_metadata.get("class") == "ce" and mars_metadata.get("stream") == "efas" - else 0 - ) + # types have no anoffset in the request, so this defaults to 0h (no-op), + # and it is restricted to efas so all other streams are unaffected. + efas_anoffset = efas_anoffset_hours(mars_metadata) for date in fields["dates"]: coordinates[date] = [] @@ -309,21 +282,7 @@ 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"]: - date_format = "%Y%m%dT%H%M%S" - new_date = pd.Timestamp(date).strftime(date_format) - start_time = datetime.strptime(new_date, date_format) - # add current date to list by converting it to iso format - if isinstance(step, timedelta): - stamp = start_time + step - else: - try: - int(step) - except ValueError: - step = step[0] - stamp = start_time + timedelta(hours=int(step)) - if efas_anoffset: - stamp -= timedelta(hours=efas_anoffset) - coordinates[date][i]["t"].append(stamp.isoformat() + "Z") + coordinates[date][i]["t"].append(valid_time(date, step, efas_anoffset)) break break break diff --git a/covjsonkit/encoder/VerticalProfile.py b/covjsonkit/encoder/VerticalProfile.py index d3d5825..5b4bd49 100644 --- a/covjsonkit/encoder/VerticalProfile.py +++ b/covjsonkit/encoder/VerticalProfile.py @@ -1,10 +1,13 @@ import logging import time -from datetime import datetime, timedelta -import pandas as pd - -from .encoder import Encoder, is_reanalysis, normalize_step_value +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class VerticalProfile(Encoder): @@ -160,29 +163,22 @@ def from_polytope(self, result, date_key: str = "date") -> dict: points = len(coords[fields["dates"][0]]["composite"]) + # Regular forecast valid-time correction for ``class=ce, stream=efas``: + # valid_datetime = forecast_issue_datetime + step - anoffset (0h no-op + # otherwise, and restricted to efas so other streams are unaffected). + efas_anoffset = efas_anoffset_hours(mars_metadata) + for date in fields["dates"]: coordinates[date] = {} for i, point in enumerate(range(points)): coordinates[date][i] = [] for step in fields["step"]: - date_format = "%Y%m%dT%H%M%S" - new_date = pd.Timestamp(date).strftime(date_format) - start_time = datetime.strptime(new_date, date_format) - # add current date to list by converting it to iso format - if isinstance(step, timedelta): - stamp = start_time + step - else: - try: - int(step) - except ValueError: - step = step[0] - stamp = start_time + timedelta(hours=int(step)) coordinates[date][i].append( { "latitude": [coords[date]["composite"][i][0]], "longitude": [coords[date]["composite"][i][1]], "levelist": list(levels), - "t": [stamp.isoformat() + "Z"], + "t": [valid_time(date, step, efas_anoffset)], } ) diff --git a/covjsonkit/encoder/Wkt.py b/covjsonkit/encoder/Wkt.py index cd552cb..6a1d4ec 100644 --- a/covjsonkit/encoder/Wkt.py +++ b/covjsonkit/encoder/Wkt.py @@ -4,7 +4,13 @@ import pandas as pd -from .encoder import Encoder, is_reanalysis, normalize_step_value, valid_time +from .encoder import ( + Encoder, + efas_anoffset_hours, + is_reanalysis, + normalize_step_value, + valid_time, +) class Wkt(Encoder): @@ -195,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, efas_anoffset_hours(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 6330ef3..f8b4962 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -196,13 +196,19 @@ def normalize_step_value(step): return str(step) -def valid_time(date, step): +def valid_time(date, step, anoffset_hours=0): """Return the ISO-8601 valid-time string for a forecast ``date`` and ``step``. - The valid time is ``date + step``. ``date`` may be any value accepted by - ``pandas.Timestamp`` (e.g. a MARS ``"YYYYMMDDTHHMMSS"`` string, optionally - suffixed with ``"Z"``). ``step`` may be a ``timedelta``/``numpy.timedelta64`` - or an hour count (int, float, numeric string, or a single-element list). + The valid time is ``date + step - anoffset``. ``date`` may be any value + accepted by ``pandas.Timestamp`` (e.g. a MARS ``"YYYYMMDDTHHMMSS"`` string, + optionally suffixed with ``"Z"``). ``step`` may be a + ``timedelta``/``numpy.timedelta64`` or an hour count (int, float, numeric + string, or a single-element list). + + ``anoffset_hours`` (default ``0``) shifts the analysis reference time + backward: ``step`` is counted from that shifted reference, so the offset is + subtracted from the valid-time. Use :func:`efas_anoffset_hours` to derive it + from ``mars_metadata`` (it is non-zero only for ``class=ce``/``stream=efas``). The result is an ISO-8601 string with a trailing ``"Z"``, matching the valid-time strings produced by the PointSeries encoders. @@ -220,9 +226,46 @@ def valid_time(date, step): except (TypeError, ValueError): hours = int(str(step).split("h")[0]) stamp = start_time + timedelta(hours=hours) + if anoffset_hours: + stamp -= timedelta(hours=anoffset_hours) return stamp.isoformat() + "Z" +def anoffset_hours(mars_metadata) -> int: + """Return the ``anoffset`` (in hours) from ``mars_metadata`` as an int. + + ``anoffset`` shifts the analysis reference time *backward* from the forecast + issue time (``date``/``time``). ``step`` is counted from that shifted + reference, so the valid-time must be corrected by subtracting ``anoffset`` + hours. Returns ``0`` when ``anoffset`` is absent (the common case). + """ + raw = mars_metadata.get("anoffset") + if raw is None: + return 0 + if isinstance(raw, (list, tuple)): + raw = raw[0] + try: + return int(raw) + except (TypeError, ValueError): + try: + return int(str(raw).split("h")[0]) + except (TypeError, ValueError): + return 0 + + +def efas_anoffset_hours(mars_metadata) -> int: + """Return the anoffset correction (hours) but only for ``class=ce``/``stream=efas``. + + The ``anoffset`` valid-time correction is restricted to regular forecast data + under ``class=ce``, ``stream=efas``. Other streams may carry an ``anoffset`` + in the request, but their valid-times must not be shifted, so this returns + ``0`` for them. Returns ``0`` when ``anoffset`` is absent as well. + """ + if mars_metadata.get("class") == "ce" and mars_metadata.get("stream") == "efas": + return anoffset_hours(mars_metadata) + return 0 + + def is_reanalysis(mars_metadata: dict, date_key: str = "date") -> bool: """True for the collapsed reanalysis path (``class=ce``, ``stream=efcl``). From 1fabb377e4c8d35d56ddc26e18b87e484b83efde Mon Sep 17 00:00:00 2001 From: awarde96 Date: Tue, 22 Sep 2026 12:48:28 +0100 Subject: [PATCH 4/6] Add tests for anoffset changes --- tests/conftest.py | 37 +++++++++ ...test_encoder_bounding_box_from_polytope.py | 23 ++++++ tests/test_encoder_circle_from_polytope.py | 23 ++++++ tests/test_encoder_frame_from_polytope.py | 23 ++++++ tests/test_encoder_grid_from_polytope.py | 23 ++++++ tests/test_encoder_helpers.py | 77 +++++++++++++++++++ tests/test_encoder_path_from_polytope.py | 29 +++++++ tests/test_encoder_position_from_polytope.py | 23 ++++++ tests/test_encoder_shapefile_from_polytope.py | 23 ++++++ ..._encoder_vertical_profile_from_polytope.py | 32 +++++++- tests/test_encoder_wkt_from_polytope.py | 23 ++++++ 11 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 tests/test_encoder_helpers.py diff --git a/tests/conftest.py b/tests/conftest.py index 5f0c043..45a6104 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -205,3 +205,40 @@ def reforecast_tree(branches, date=np.datetime64("2024-03-01")): for b in branches: root.add_child(b) 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_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..291aee2 --- /dev/null +++ b/tests/test_encoder_helpers.py @@ -0,0 +1,77 @@ +"""Unit tests for the shared valid-time / anoffset helpers in encoder.py.""" + +import numpy as np + +from covjsonkit.encoder.encoder import anoffset_hours, efas_anoffset_hours, 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, 6) == "2025-01-01T00:00:00Z" + + def test_anoffset_partial(self): + # 00:00 + step 6h - anoffset 3h = 03:00 + assert valid_time("20250101T000000", 6, 3) == "2025-01-01T03:00:00Z" + + def test_anoffset_zero_is_noop(self): + assert valid_time("20250101T000000", 6, 0) == "2025-01-01T06:00:00Z" + + +class TestAnoffsetHours: + def test_absent_returns_zero(self): + assert anoffset_hours({}) == 0 + + def test_none_returns_zero(self): + assert anoffset_hours({"anoffset": None}) == 0 + + def test_int_value(self): + assert anoffset_hours({"anoffset": 6}) == 6 + + def test_list_value(self): + assert anoffset_hours({"anoffset": [6]}) == 6 + + def test_hour_suffixed_string(self): + assert anoffset_hours({"anoffset": "6h"}) == 6 + + def test_unparseable_returns_zero(self): + assert anoffset_hours({"anoffset": "garbage"}) == 0 + + +class TestEfasAnoffsetHours: + def test_efas_with_anoffset(self): + mm = {"class": "ce", "stream": "efas", "anoffset": 6} + assert efas_anoffset_hours(mm) == 6 + + def test_efas_without_anoffset(self): + mm = {"class": "ce", "stream": "efas"} + assert efas_anoffset_hours(mm) == 0 + + def test_non_efas_stream_ignored(self): + # anoffset present but stream is not efas -> not applied. + mm = {"class": "od", "stream": "oper", "anoffset": 6} + assert efas_anoffset_hours(mm) == 0 + + def test_efcl_stream_ignored(self): + # Reanalysis (efcl) must not receive the efas correction. + mm = {"class": "ce", "stream": "efcl", "anoffset": 6} + assert efas_anoffset_hours(mm) == 0 + + def test_wrong_class_ignored(self): + mm = {"class": "od", "stream": "efas", "anoffset": 6} + assert efas_anoffset_hours(mm) == 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 6f2568d..e9f1789 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_vertical_profile_from_polytope.py b/tests/test_encoder_vertical_profile_from_polytope.py index c9d4f04..3610069 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"] From bed3ff263b4934c5ba834f9c3a920ee1a4417849 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Fri, 16 Oct 2026 09:56:00 +0200 Subject: [PATCH 5/6] Update tests with anoffset changes --- covjsonkit/decoder/VerticalProfile.py | 6 +- tests/conftest.py | 20 +++- tests/test_encoder_anoffset.py | 132 ++++++++++++++++++++++++++ tests/test_encoder_helpers.py | 59 +++++++----- 4 files changed, 186 insertions(+), 31 deletions(-) create mode 100644 tests/test_encoder_anoffset.py 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/tests/conftest.py b/tests/conftest.py index 7729ee3..92bdd02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -261,6 +261,9 @@ def forecast_separate_datetime_tree( step=(6, 24), param="240023", point_factory=make_point, + anoffset=None, + levels=None, + type_="cf", ): """Build a separate-datetime *forecast* tree (class=ce, stream=efas). @@ -276,20 +279,29 @@ 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)) diff --git a/tests/test_encoder_anoffset.py b/tests/test_encoder_anoffset.py new file mode 100644 index 0000000..8d0921b --- /dev/null +++ b/tests/test_encoder_anoffset.py @@ -0,0 +1,132 @@ +"""anoffset on the separate-datetime path (``from_polytope_reforecast``) used for every class=ce request. + +For ``class=ce, stream=efas`` 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) diff --git a/tests/test_encoder_helpers.py b/tests/test_encoder_helpers.py index 291aee2..27a80aa 100644 --- a/tests/test_encoder_helpers.py +++ b/tests/test_encoder_helpers.py @@ -1,8 +1,11 @@ """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_hours, efas_anoffset_hours, valid_time +from covjsonkit.encoder.encoder import anoffset_timedelta, anoffset_value, valid_time class TestValidTime: @@ -23,55 +26,61 @@ def test_single_element_list_step(self): def test_anoffset_subtracted(self): # 00:00 + step 6h - anoffset 6h = 00:00 - assert valid_time("20250101T000000", 6, 6) == "2025-01-01T00:00:00Z" + 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, 3) == "2025-01-01T03:00:00Z" + assert valid_time("20250101T000000", 6, anoffset=3) == "2025-01-01T03:00:00Z" def test_anoffset_zero_is_noop(self): - assert valid_time("20250101T000000", 6, 0) == "2025-01-01T06:00:00Z" - - -class TestAnoffsetHours: - def test_absent_returns_zero(self): - assert anoffset_hours({}) == 0 + assert valid_time("20250101T000000", 6, anoffset=0) == "2025-01-01T06:00:00Z" - def test_none_returns_zero(self): - assert anoffset_hours({"anoffset": None}) == 0 + 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" - def test_int_value(self): - assert anoffset_hours({"anoffset": 6}) == 6 - def test_list_value(self): - assert anoffset_hours({"anoffset": [6]}) == 6 +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_hour_suffixed_string(self): - assert anoffset_hours({"anoffset": "6h"}) == 6 + def test_multiple_values_rejected(self): + with pytest.raises(ValueError, match="single anoffset"): + anoffset_value([6, 18]) - def test_unparseable_returns_zero(self): - assert anoffset_hours({"anoffset": "garbage"}) == 0 + def test_unparseable_rejected(self): + with pytest.raises(ValueError): + anoffset_value("garbage") -class TestEfasAnoffsetHours: +class TestAnoffsetTimedelta: def test_efas_with_anoffset(self): mm = {"class": "ce", "stream": "efas", "anoffset": 6} - assert efas_anoffset_hours(mm) == 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 efas_anoffset_hours(mm) == 0 + 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) def test_non_efas_stream_ignored(self): # anoffset present but stream is not efas -> not applied. mm = {"class": "od", "stream": "oper", "anoffset": 6} - assert efas_anoffset_hours(mm) == 0 + assert anoffset_timedelta(mm) == timedelta(0) def test_efcl_stream_ignored(self): # Reanalysis (efcl) must not receive the efas correction. mm = {"class": "ce", "stream": "efcl", "anoffset": 6} - assert efas_anoffset_hours(mm) == 0 + assert anoffset_timedelta(mm) == timedelta(0) def test_wrong_class_ignored(self): mm = {"class": "od", "stream": "efas", "anoffset": 6} - assert efas_anoffset_hours(mm) == 0 + assert anoffset_timedelta(mm) == timedelta(0) From dab67188222cda22b40a71ea4ae03541ad8228b8 Mon Sep 17 00:00:00 2001 From: awarde96 Date: Fri, 16 Oct 2026 14:42:24 +0200 Subject: [PATCH 6/6] Allow anoffset for class ef and gf --- covjsonkit/encoder/TimeSeries.py | 2 +- covjsonkit/encoder/encoder.py | 12 ++++++++---- tests/conftest.py | 3 ++- tests/test_encoder_anoffset.py | 9 ++++++++- tests/test_encoder_helpers.py | 17 +++++++++-------- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/covjsonkit/encoder/TimeSeries.py b/covjsonkit/encoder/TimeSeries.py index c2c5801..5c89e9b 100644 --- a/covjsonkit/encoder/TimeSeries.py +++ b/covjsonkit/encoder/TimeSeries.py @@ -418,7 +418,7 @@ def emit(full_path, flat_result): time_off = d.get("time") if "anoffset" in d: anoffsets["anoffset"] = self._single_anoffset(anoffsets, d["anoffset"]) - # efas: step is counted from the analysis reference, anoffset before the run. + # 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: diff --git a/covjsonkit/encoder/encoder.py b/covjsonkit/encoder/encoder.py index 61817f0..48ebb65 100644 --- a/covjsonkit/encoder/encoder.py +++ b/covjsonkit/encoder/encoder.py @@ -307,15 +307,19 @@ def anoffset_value(values): 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 ``class=ce, stream=efas`` only. + """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 streams may carry an + ``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") != "ce" or mars_metadata.get("stream") != "efas": + if mars_metadata.get("class") not in ANOFFSET_CLASSES: return timedelta(0) value = mars_metadata.get("anoffset") if value is None: @@ -1214,7 +1218,7 @@ 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`. For ``stream=efas`` the valid time is shifted + :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)``. diff --git a/tests/conftest.py b/tests/conftest.py index 92bdd02..a63bcb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -264,6 +264,7 @@ def forecast_separate_datetime_tree( anoffset=None, levels=None, type_="cf", + cls="ce", ): """Build a separate-datetime *forecast* tree (class=ce, stream=efas). @@ -308,7 +309,7 @@ def forecast_separate_datetime_tree( tree = chain( TensorIndexTree(), - node("class", ("ce",)), + node("class", (cls,)), ) tip(tree).add_child(branch) return tree diff --git a/tests/test_encoder_anoffset.py b/tests/test_encoder_anoffset.py index 8d0921b..04ef1bb 100644 --- a/tests/test_encoder_anoffset.py +++ b/tests/test_encoder_anoffset.py @@ -1,6 +1,6 @@ """anoffset on the separate-datetime path (``from_polytope_reforecast``) used for every class=ce request. -For ``class=ce, stream=efas`` the valid time is ``date + time + step - anoffset`` while +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. """ @@ -130,3 +130,10 @@ def test_multiple_anoffsets_rejected_on_from_polytope_path(): 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_helpers.py b/tests/test_encoder_helpers.py index 27a80aa..176388c 100644 --- a/tests/test_encoder_helpers.py +++ b/tests/test_encoder_helpers.py @@ -71,16 +71,17 @@ def test_none_is_zero(self): mm = {"class": "ce", "stream": "efas", "anoffset": None} assert anoffset_timedelta(mm) == timedelta(0) - def test_non_efas_stream_ignored(self): - # anoffset present but stream is not efas -> not applied. - mm = {"class": "od", "stream": "oper", "anoffset": 6} - 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_efcl_stream_ignored(self): - # Reanalysis (efcl) must not receive the efas correction. - mm = {"class": "ce", "stream": "efcl", "anoffset": 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_wrong_class_ignored(self): + def test_other_class_with_efas_stream_ignored(self): mm = {"class": "od", "stream": "efas", "anoffset": 6} assert anoffset_timedelta(mm) == timedelta(0)