covjsonkit 0.2.16__tar.gz → 0.2.18__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/PKG-INFO +1 -1
  2. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/BoundingBox.py +2 -1
  3. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Circle.py +2 -1
  4. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Path.py +2 -1
  5. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/VerticalProfile.py +3 -2
  6. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/BoundingBox.py +3 -3
  7. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Circle.py +3 -3
  8. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Frame.py +3 -3
  9. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Grid.py +8 -5
  10. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Path.py +2 -2
  11. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Position.py +8 -5
  12. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Shapefile.py +3 -3
  13. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/VerticalProfile.py +2 -2
  14. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/Wkt.py +3 -3
  15. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/encoder.py +188 -3
  16. covjsonkit-0.2.18/covjsonkit/version.py +1 -0
  17. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit.egg-info/PKG-INFO +1 -1
  18. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit.egg-info/SOURCES.txt +1 -0
  19. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/pyproject.toml +1 -1
  20. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/conftest.py +1 -1
  21. covjsonkit-0.2.18/tests/geojsontest.py +16 -0
  22. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_bounding_box_from_polytope.py +1 -1
  23. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_position_from_polytope.py +2 -2
  24. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_time_series_from_polytope.py +43 -4
  25. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_vertical_profile_from_polytope.py +1 -1
  26. covjsonkit-0.2.16/covjsonkit/version.py +0 -1
  27. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/.github/ci-hpc-config.yml +0 -0
  28. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/.github/workflows/ci.yaml +0 -0
  29. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/.github/workflows/label-public-pr.yml +0 -0
  30. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/.gitignore +0 -0
  31. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/.pre-commit-config.yaml +0 -0
  32. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/ACKNOWLEDGEMENTS.rst +0 -0
  33. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/LICENSE +0 -0
  34. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/MANIFEST.in +0 -0
  35. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/Makefile +0 -0
  36. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/README.md +0 -0
  37. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/Coverage.py +0 -0
  38. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/CoverageCollection.py +0 -0
  39. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/__init__.py +0 -0
  40. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/api.py +0 -0
  41. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/config.py +0 -0
  42. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/__init__.py +0 -0
  43. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/dwd/param.json +0 -0
  44. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/dwd/param_id.json +0 -0
  45. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/dwd/unit.json +0 -0
  46. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/ecmwf/param.json +0 -0
  47. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/ecmwf/param_id.json +0 -0
  48. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/data/ecmwf/unit.json +0 -0
  49. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Frame.py +0 -0
  50. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Grid.py +0 -0
  51. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Position.py +0 -0
  52. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Shapefile.py +0 -0
  53. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/TimeSeries.py +0 -0
  54. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/Wkt.py +0 -0
  55. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/__init__.py +0 -0
  56. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/decoder/decoder.py +0 -0
  57. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/TimeSeries.py +0 -0
  58. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/encoder/__init__.py +0 -0
  59. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/param_db.py +0 -0
  60. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit/utils.py +0 -0
  61. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit.egg-info/dependency_links.txt +0 -0
  62. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit.egg-info/not-zip-safe +0 -0
  63. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit.egg-info/requires.txt +0 -0
  64. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/covjsonkit.egg-info/top_level.txt +0 -0
  65. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/docs/images/ECMWF_logo.svg.png +0 -0
  66. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/docs/images/Logo_Destination_Earth_Colours.png +0 -0
  67. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/example_config.json +0 -0
  68. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/requirements.txt +0 -0
  69. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/scripts/generate_param_db.py +0 -0
  70. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/setup.cfg +0 -0
  71. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/setup.py +0 -0
  72. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_coverage1.json +0 -0
  73. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_coverage2.json +0 -0
  74. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_geotiff.json +0 -0
  75. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_multipoint_coverage.json +0 -0
  76. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_path_coverage.json +0 -0
  77. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_timeseries_coverage.json +0 -0
  78. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_timeseries_xyz_coverage.json +0 -0
  79. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_verticalprofile_coverage.json +0 -0
  80. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/data/test_verticalprofile_xyz_coverage.json +0 -0
  81. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/requirements_test.txt +0 -0
  82. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_bounding_box.py +0 -0
  83. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_frame.py +0 -0
  84. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_grid.py +0 -0
  85. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_shapefile.py +0 -0
  86. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_time_series.py +0 -0
  87. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_vertical_profile.py +0 -0
  88. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_decoder_wkt.py +0 -0
  89. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_bounding_box.py +0 -0
  90. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_circle_from_polytope.py +0 -0
  91. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_frame.py +0 -0
  92. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_frame_from_polytope.py +0 -0
  93. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_grid.py +0 -0
  94. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_grid_from_polytope.py +0 -0
  95. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_path.py +0 -0
  96. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_path_from_polytope.py +0 -0
  97. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_shapefile.py +0 -0
  98. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_shapefile_from_polytope.py +0 -0
  99. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_time_series.py +0 -0
  100. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_wkt.py +0 -0
  101. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_encoder_wkt_from_polytope.py +0 -0
  102. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_geojson.py +0 -0
  103. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_geotiff.py +0 -0
  104. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_util_merge.py +0 -0
  105. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_xarray_multipoint.py +0 -0
  106. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_xarray_path.py +0 -0
  107. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_xarray_ts.py +0 -0
  108. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tests/test_xarray_vp.py +0 -0
  109. {covjsonkit-0.2.16 → covjsonkit-0.2.18}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: covjsonkit
3
- Version: 0.2.16
3
+ Version: 0.2.18
4
4
  Home-page: https://github.com/ecmwf/covjsonkit
5
5
  Author: ECMWF
6
6
  Author-email: James.Hawkes@ecmwf.int, Adam.Warde@ecmwf.int, Mathilde.Leuridan@ecmwf.int
@@ -8,6 +8,7 @@ except ImportError:
8
8
  import xarray as xr
9
9
  from scipy.spatial import cKDTree
10
10
 
11
+ from ..encoder.encoder import sort_step_values
11
12
  from .decoder import Decoder
12
13
 
13
14
 
@@ -177,7 +178,7 @@ class BoundingBox(Decoder):
177
178
 
178
179
  datetimes = sorted(list(set(datetimes)))
179
180
  numbers = sorted(list(set(numbers)))
180
- steps = sorted(list(set(steps)))
181
+ steps = sort_step_values(list(set(steps)))
181
182
 
182
183
  new_values = {}
183
184
  for parameter in values.keys():
@@ -1,5 +1,6 @@
1
1
  import xarray as xr
2
2
 
3
+ from ..encoder.encoder import sort_step_values
3
4
  from .decoder import Decoder
4
5
 
5
6
 
@@ -119,7 +120,7 @@ class Circle(Decoder):
119
120
 
120
121
  datetimes = sorted(list(set(datetimes)))
121
122
  numbers = sorted(list(set(numbers)))
122
- steps = sorted(list(set(steps)))
123
+ steps = sort_step_values(list(set(steps)))
123
124
 
124
125
  new_values = {}
125
126
  for parameter in values.keys():
@@ -1,5 +1,6 @@
1
1
  import xarray as xr
2
2
 
3
+ from ..encoder.encoder import sort_step_values
3
4
  from .decoder import Decoder
4
5
 
5
6
 
@@ -117,7 +118,7 @@ class Path(Decoder):
117
118
 
118
119
  datetimes = list(set(datetimes))
119
120
  numbers = list(set(numbers))
120
- steps = list(set(steps))
121
+ steps = sort_step_values(list(set(steps)))
121
122
 
122
123
  new_values = {}
123
124
  for parameter in values.keys():
@@ -3,6 +3,7 @@ from datetime import timedelta
3
3
 
4
4
  import xarray as xr
5
5
 
6
+ from ..encoder.encoder import parse_step_string, sort_step_values
6
7
  from .decoder import Decoder
7
8
 
8
9
 
@@ -157,7 +158,7 @@ class VerticalProfile(Decoder):
157
158
 
158
159
  nums = list(set(num))
159
160
  datetime = list(set(datetime))
160
- steps = list(set(steps))
161
+ steps = sort_step_values(list(set(steps)))
161
162
 
162
163
  for parameter in self.parameters:
163
164
  if len(param_values[parameter]) <= domain_idx:
@@ -176,7 +177,7 @@ class VerticalProfile(Decoder):
176
177
  param_values[parameter][domain_idx][i][j].append([])
177
178
  for coverage in self.covjson["coverages"]:
178
179
  new_step = (
179
- dt.fromisoformat(date.replace("Z", "")) + timedelta(hours=int(step))
180
+ dt.fromisoformat(date.replace("Z", "")) + timedelta(hours=parse_step_string(step))
180
181
  ).isoformat() + "Z"
181
182
  if (
182
183
  coverage["mars:metadata"]["number"] == num
@@ -3,7 +3,7 @@ import time
3
3
 
4
4
  import pandas as pd
5
5
 
6
- from .encoder import Encoder
6
+ from .encoder import Encoder, normalize_step_value
7
7
 
8
8
 
9
9
  class BoundingBox(Encoder):
@@ -107,7 +107,7 @@ class BoundingBox(Encoder):
107
107
  dv_dict = {}
108
108
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
109
109
  mars_metadata["number"] = int(num)
110
- mars_metadata["step"] = int(step)
110
+ mars_metadata["step"] = normalize_step_value(step)
111
111
  mars_metadata["Forecast date"] = str(datetime)
112
112
  for dv in dataset.data_vars:
113
113
  dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist()
@@ -196,7 +196,7 @@ class BoundingBox(Encoder):
196
196
  for step in val_dict.keys():
197
197
  mm = mars_metadata.copy()
198
198
  mm["number"] = num
199
- mm["step"] = step
199
+ mm["step"] = normalize_step_value(step)
200
200
  mm["Forecast date"] = date
201
201
  self.add_coverage(mm, coords[date], val_dict[step])
202
202
 
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from .encoder import Encoder
3
+ from .encoder import Encoder, normalize_step_value
4
4
 
5
5
 
6
6
  class Circle(Encoder):
@@ -104,7 +104,7 @@ class Circle(Encoder):
104
104
  dv_dict = {}
105
105
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
106
106
  mars_metadata["number"] = int(num)
107
- mars_metadata["step"] = int(step)
107
+ mars_metadata["step"] = normalize_step_value(step)
108
108
  mars_metadata["Forecast date"] = str(datetime)
109
109
  for dv in dataset.data_vars:
110
110
  dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist()
@@ -194,7 +194,7 @@ class Circle(Encoder):
194
194
  for step in val_dict.keys():
195
195
  mm = mars_metadata.copy()
196
196
  mm["number"] = num
197
- mm["step"] = step
197
+ mm["step"] = normalize_step_value(step)
198
198
  mm["Forecast date"] = date
199
199
  self.add_coverage(mm, coords[date], val_dict[step])
200
200
 
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from .encoder import Encoder
3
+ from .encoder import Encoder, normalize_step_value
4
4
 
5
5
 
6
6
  class Frame(Encoder):
@@ -104,7 +104,7 @@ class Frame(Encoder):
104
104
  dv_dict = {}
105
105
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
106
106
  mars_metadata["number"] = int(num)
107
- mars_metadata["step"] = int(step)
107
+ mars_metadata["step"] = normalize_step_value(step)
108
108
  mars_metadata["Forecast date"] = str(datetime)
109
109
  for dv in dataset.data_vars:
110
110
  dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist()
@@ -189,7 +189,7 @@ class Frame(Encoder):
189
189
  for step in val_dict.keys():
190
190
  mm = mars_metadata.copy()
191
191
  mm["number"] = num
192
- mm["step"] = step
192
+ mm["step"] = normalize_step_value(step)
193
193
  mm["Forecast date"] = date
194
194
  self.add_coverage(mm, coords[date], val_dict[step])
195
195
 
@@ -1,9 +1,10 @@
1
1
  import logging
2
2
  import time
3
+ from datetime import timedelta
3
4
 
4
5
  import pandas as pd
5
6
 
6
- from .encoder import Encoder
7
+ from .encoder import Encoder, normalize_step_value
7
8
 
8
9
 
9
10
  class Grid(Encoder):
@@ -105,7 +106,7 @@ class Grid(Encoder):
105
106
  dv_dict = {}
106
107
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
107
108
  mars_metadata["number"] = int(num)
108
- mars_metadata["step"] = int(step)
109
+ mars_metadata["step"] = normalize_step_value(step)
109
110
  mars_metadata["Forecast date"] = str(datetime)
110
111
  for dv in dataset.data_vars:
111
112
  nested_list = dataset[dv].sel(datetimes=datetime, number=num, steps=step).values.tolist()
@@ -181,11 +182,13 @@ class Grid(Encoder):
181
182
  logging.debug("The range_dict created was: %s", range_dict) # noqa: E501
182
183
 
183
184
  coordinates = {}
184
- coordinates["t"] = list(fields["step"])
185
+ coordinates["t"] = [int(s.total_seconds() // 3600) if isinstance(s, timedelta) else s for s in fields["step"]]
185
186
 
186
187
  for date in coords.keys():
187
188
  coordinates[date] = {}
188
- coordinates[date]["t"] = list(fields["step"])
189
+ coordinates[date]["t"] = [
190
+ int(s.total_seconds() // 3600) if isinstance(s, timedelta) else s for s in fields["step"]
191
+ ]
189
192
  coordinates[date]["levelist"] = list(fields["levels"])
190
193
  coordinates[date]["latitude"] = []
191
194
  coordinates[date]["longitude"] = []
@@ -211,7 +214,7 @@ class Grid(Encoder):
211
214
  val_dict[para].extend(combined_dict[date][num][para][step])
212
215
  mm = mars_metadata.copy()
213
216
  mm["number"] = num
214
- mm["step"] = step
217
+ mm["step"] = normalize_step_value(step)
215
218
  mm["Forecast date"] = date
216
219
  self.add_coverage(mm, coordinates[date], val_dict)
217
220
 
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from .encoder import Encoder
3
+ from .encoder import Encoder, normalize_step_value
4
4
 
5
5
 
6
6
  class Path(Encoder):
@@ -104,7 +104,7 @@ class Path(Encoder):
104
104
  dv_dict = {}
105
105
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
106
106
  mars_metadata["number"] = int(num)
107
- mars_metadata["step"] = int(step)
107
+ mars_metadata["step"] = normalize_step_value(step)
108
108
  mars_metadata["Forecast date"] = str(datetime)
109
109
  for dv in dataset.data_vars:
110
110
  dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist()
@@ -196,11 +196,14 @@ class Position(Encoder):
196
196
  new_date = pd.Timestamp(date).strftime(date_format)
197
197
  start_time = datetime.strptime(new_date, date_format)
198
198
  # add current date to list by converting it to iso format
199
- try:
200
- int(step)
201
- except ValueError:
202
- step = step[0]
203
- stamp = start_time + timedelta(hours=int(step))
199
+ if isinstance(step, timedelta):
200
+ stamp = start_time + step
201
+ else:
202
+ try:
203
+ int(step)
204
+ except ValueError:
205
+ step = step[0]
206
+ stamp = start_time + timedelta(hours=int(step))
204
207
  coordinates[date][i]["t"].append(stamp.isoformat() + "Z")
205
208
  break
206
209
  break
@@ -1,6 +1,6 @@
1
1
  import logging
2
2
 
3
- from .encoder import Encoder
3
+ from .encoder import Encoder, normalize_step_value
4
4
 
5
5
 
6
6
  class Shapefile(Encoder):
@@ -104,7 +104,7 @@ class Shapefile(Encoder):
104
104
  dv_dict = {}
105
105
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
106
106
  mars_metadata["number"] = int(num)
107
- mars_metadata["step"] = int(step)
107
+ mars_metadata["step"] = normalize_step_value(step)
108
108
  mars_metadata["Forecast date"] = str(datetime)
109
109
  for dv in dataset.data_vars:
110
110
  dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist()
@@ -189,7 +189,7 @@ class Shapefile(Encoder):
189
189
  for step in val_dict.keys():
190
190
  mm = mars_metadata.copy()
191
191
  mm["number"] = num
192
- mm["step"] = step
192
+ mm["step"] = normalize_step_value(step)
193
193
  mm["Forecast date"] = date
194
194
  self.add_coverage(mm, coords[date], val_dict[step])
195
195
 
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
4
4
 
5
5
  import pandas as pd
6
6
 
7
- from .encoder import Encoder
7
+ from .encoder import Encoder, normalize_step_value
8
8
 
9
9
 
10
10
  class VerticalProfile(Encoder):
@@ -106,7 +106,7 @@ class VerticalProfile(Encoder):
106
106
  for metadata in dataset.attrs:
107
107
  mars_metadata[metadata] = dataset.attrs[metadata]
108
108
  mars_metadata["number"] = int(num)
109
- mars_metadata["step"] = int(step)
109
+ mars_metadata["step"] = normalize_step_value(step)
110
110
 
111
111
  self.add_coverage(
112
112
  mars_metadata,
@@ -4,7 +4,7 @@ import time
4
4
 
5
5
  import pandas as pd
6
6
 
7
- from .encoder import Encoder
7
+ from .encoder import Encoder, normalize_step_value
8
8
 
9
9
 
10
10
  class Wkt(Encoder):
@@ -108,7 +108,7 @@ class Wkt(Encoder):
108
108
  dv_dict = {}
109
109
  mars_metadata = {metadata: dataset.attrs[metadata] for metadata in dataset.attrs}
110
110
  mars_metadata["number"] = int(num)
111
- mars_metadata["step"] = int(step)
111
+ mars_metadata["step"] = normalize_step_value(step)
112
112
  mars_metadata["Forecast date"] = str(datetime)
113
113
  for dv in dataset.data_vars:
114
114
  dv_dict[dv] = dataset[dv].sel(number=num, steps=step, datetimes=datetime).values.tolist()
@@ -198,7 +198,7 @@ class Wkt(Encoder):
198
198
  for step in val_dict.keys():
199
199
  mm = mars_metadata.copy()
200
200
  mm["number"] = num
201
- mm["step"] = step
201
+ mm["step"] = normalize_step_value(step)
202
202
  mm["Forecast date"] = date
203
203
  self.add_coverage(mm, coords[date], val_dict[step])
204
204
 
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
+ from datetime import timedelta
4
5
  from typing import Any
5
6
 
7
+ import numpy as np
6
8
  import orjson
7
9
  import pandas as pd
8
10
  from covjson_pydantic.coverage import CoverageCollection
@@ -11,6 +13,170 @@ from covjson_pydantic.domain import DomainType
11
13
  from covjsonkit.param_db import get_param_ids, get_params, get_units
12
14
 
13
15
 
16
+ def timedelta_to_step_string(td: timedelta) -> str:
17
+ """
18
+ Convert a timedelta object to a step string in the format 'XhYm'.
19
+
20
+ Args:
21
+ td: timedelta object representing the step
22
+
23
+ Returns:
24
+ String in format 'Xh', 'Ym', or 'XhYm' depending on the timedelta value
25
+
26
+ Examples:
27
+ timedelta(hours=13) -> "13h"
28
+ timedelta(hours=13, minutes=30) -> "13h30m"
29
+ timedelta(minutes=30) -> "30m"
30
+ timedelta(hours=0, minutes=0) -> "0h"
31
+ """
32
+ total_seconds = int(td.total_seconds())
33
+ hours = total_seconds // 3600
34
+ minutes = (total_seconds % 3600) // 60
35
+
36
+ if hours > 0 and minutes > 0:
37
+ return f"{hours}h{minutes}m"
38
+ elif hours > 0:
39
+ return f"{hours}h"
40
+ elif minutes > 0:
41
+ return f"{minutes}m"
42
+ else:
43
+ return "0h"
44
+
45
+
46
+ def parse_step_string(step_str: str) -> float:
47
+ """
48
+ Parse a step string in format 'XhYm' and return total hours as float.
49
+
50
+ Args:
51
+ step_str: Step string in format 'Xh', 'Ym', or 'XhYm'
52
+
53
+ Returns:
54
+ Total hours as a float value
55
+
56
+ Examples:
57
+ parse_step_string("13h") -> 13.0
58
+ parse_step_string("13h30m") -> 13.5
59
+ parse_step_string("30m") -> 0.5
60
+ parse_step_string("0h") -> 0.0
61
+ """
62
+ if isinstance(step_str, (int, float)):
63
+ return float(step_str)
64
+
65
+ step_str = str(step_str)
66
+ hours = 0.0
67
+ minutes = 0.0
68
+
69
+ # Parse hours
70
+ if "h" in step_str:
71
+ parts = step_str.split("h")
72
+ hours = float(parts[0])
73
+ step_str = parts[1] if len(parts) > 1 else ""
74
+
75
+ # Parse minutes
76
+ if "m" in step_str and step_str:
77
+ minutes = float(step_str.replace("m", ""))
78
+
79
+ return hours + (minutes / 60.0)
80
+
81
+
82
+ def sort_step_values(steps: list) -> list:
83
+ """
84
+ Sort a list of step values that might be in various formats.
85
+
86
+ Args:
87
+ steps: List of step values (strings like "13h30m", integers, or floats)
88
+
89
+ Returns:
90
+ Sorted list of step values in their original format
91
+
92
+ Examples:
93
+ sort_step_values(["13h30m", "12h", "14h"]) -> ["12h", "13h30m", "14h"]
94
+ """
95
+ # Create tuples of (original_value, numeric_value) for sorting
96
+ step_tuples = [(step, parse_step_string(step)) for step in steps]
97
+ # Sort by numeric value
98
+ step_tuples.sort(key=lambda x: x[1])
99
+ # Return original values in sorted order
100
+ return [step[0] for step in step_tuples]
101
+
102
+
103
+ def normalize_step_value(step):
104
+ """
105
+ Normalize a step value from various formats, preserving integers when possible.
106
+
107
+ Handles:
108
+ - timedelta objects (datetime.timedelta) - converts to int if whole hours, else string "XhYm"
109
+ - numpy.timedelta64 objects - converts to int if whole hours, else string "XhYm"
110
+ - integers - returns as-is (legacy compatibility)
111
+ - floats - converts to int if whole hours, else string "XhYm"
112
+ - strings - returns as-is if already in correct format
113
+
114
+ Args:
115
+ step: Step value in various formats
116
+
117
+ Returns:
118
+ Integer for whole hour values, string in 'XhYm' format for sub-hourly values
119
+
120
+ Examples:
121
+ normalize_step_value(timedelta(hours=13, minutes=30)) -> "13h30m"
122
+ normalize_step_value(timedelta(hours=13)) -> 13
123
+ normalize_step_value(13) -> 13
124
+ normalize_step_value(13.5) -> "13h30m"
125
+ normalize_step_value("13h30m") -> "13h30m"
126
+ """
127
+ # If it's already a string, return as-is (assumes it's already in correct format)
128
+ if isinstance(step, str):
129
+ return step
130
+
131
+ # If it's a timedelta or pd.Timedelta, check if it has sub-hourly components
132
+ if isinstance(step, (timedelta, pd.Timedelta)):
133
+ if isinstance(step, pd.Timedelta):
134
+ td = step.to_pytimedelta()
135
+ else:
136
+ td = step
137
+
138
+ total_seconds = int(td.total_seconds())
139
+ hours = total_seconds // 3600
140
+ minutes = (total_seconds % 3600) // 60
141
+
142
+ # If there are minutes, return string format
143
+ if minutes > 0:
144
+ return timedelta_to_step_string(td)
145
+ # Otherwise return integer hours
146
+ return hours
147
+
148
+ # If it's a numpy timedelta64, convert to Python timedelta first
149
+ if isinstance(step, np.timedelta64):
150
+ td = pd.Timedelta(step).to_pytimedelta()
151
+ total_seconds = int(td.total_seconds())
152
+ hours = total_seconds // 3600
153
+ minutes = (total_seconds % 3600) // 60
154
+
155
+ if minutes > 0:
156
+ return timedelta_to_step_string(td)
157
+ return hours
158
+
159
+ # If it's an integer, return as-is (legacy compatibility)
160
+ if isinstance(step, (int, np.integer)):
161
+ return int(step)
162
+
163
+ # If it's a float, check if it has fractional hours
164
+ if isinstance(step, (float, np.floating)):
165
+ hours = int(step)
166
+ total_minutes = int(step * 60)
167
+ minutes = total_minutes % 60
168
+
169
+ if minutes > 0:
170
+ if hours > 0:
171
+ return f"{hours}h{minutes}m"
172
+ else:
173
+ return f"{minutes}m"
174
+ return hours
175
+
176
+ # Fallback: convert to string
177
+ return str(step)
178
+
179
+
14
180
  class Encoder(ABC):
15
181
  def __init__(self, type, domaintype):
16
182
  """
@@ -176,7 +342,15 @@ class Encoder(ABC):
176
342
  def handle_non_leaf_node(child):
177
343
  non_leaf_axes = ["latitude", "longitude", "param", date_key]
178
344
  if child.axis.name not in non_leaf_axes:
179
- mars_metadata[child.axis.name] = child.values[0]
345
+ val = child.values[0]
346
+ if isinstance(val, np.datetime64):
347
+ val = str(val)
348
+ elif isinstance(val, timedelta):
349
+ val = timedelta_to_step_string(val)
350
+ elif child.axis.name == "step":
351
+ # Step is not a timedelta! Need to normalize it
352
+ val = normalize_step_value(val)
353
+ mars_metadata[child.axis.name] = val
180
354
 
181
355
  def handle_specific_axes(child):
182
356
  if child.axis.name == "latitude":
@@ -272,7 +446,15 @@ class Encoder(ABC):
272
446
  def handle_non_leaf_node_step(child):
273
447
  non_leaf_axes = ["latitude", "longitude", "param", "date", "time"]
274
448
  if child.axis.name not in non_leaf_axes:
275
- mars_metadata[child.axis.name] = child.values[0]
449
+ val = child.values[0]
450
+ if isinstance(val, np.datetime64):
451
+ val = str(val)
452
+ elif isinstance(val, timedelta):
453
+ val = timedelta_to_step_string(val)
454
+ elif child.axis.name == "step":
455
+ # Step is not a timedelta! Need to normalize it
456
+ val = normalize_step_value(val)
457
+ mars_metadata[child.axis.name] = val
276
458
 
277
459
  def handle_specific_axes_step(child):
278
460
  if child.axis.name == "latitude":
@@ -441,7 +623,10 @@ class Encoder(ABC):
441
623
  def handle_non_leaf_node_month(child):
442
624
  non_leaf_axes = ["latitude", "longitude", "param", "year", "month"]
443
625
  if child.axis.name not in non_leaf_axes:
444
- mars_metadata[child.axis.name] = child.values[0]
626
+ val = child.values[0]
627
+ if isinstance(val, np.datetime64):
628
+ val = str(val)
629
+ mars_metadata[child.axis.name] = val
445
630
 
446
631
  def handle_specific_axes_month(child):
447
632
  if child.axis.name == "latitude":
@@ -0,0 +1 @@
1
+ __version__ = "0.2.18"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: covjsonkit
3
- Version: 0.2.16
3
+ Version: 0.2.18
4
4
  Home-page: https://github.com/ecmwf/covjsonkit
5
5
  Author: ECMWF
6
6
  Author-email: James.Hawkes@ecmwf.int, Adam.Warde@ecmwf.int, Mathilde.Leuridan@ecmwf.int
@@ -62,6 +62,7 @@ docs/images/ECMWF_logo.svg.png
62
62
  docs/images/Logo_Destination_Earth_Colours.png
63
63
  scripts/generate_param_db.py
64
64
  tests/conftest.py
65
+ tests/geojsontest.py
65
66
  tests/requirements_test.txt
66
67
  tests/test_decoder_bounding_box.py
67
68
  tests/test_decoder_frame.py
@@ -8,7 +8,7 @@ markers = ["data: uses test data (deselect with '-m \"not data\"')",]
8
8
 
9
9
  [project]
10
10
  name = "covjsonkit"
11
- version = "0.2.16"
11
+ version = "0.2.18"
12
12
  dependencies = [
13
13
  "pandas<3",
14
14
  "orjson",
@@ -6,7 +6,7 @@ from polytope_feature.datacube.tensor_index_tree import TensorIndexTree
6
6
 
7
7
  REFORECAST_METADATA_BASE = {
8
8
  "class": "ce",
9
- "date": np.datetime64("2024-03-01"),
9
+ "date": "2024-03-01",
10
10
  "domain": "g",
11
11
  "expver": "4321",
12
12
  "levtype": "sfc",
@@ -0,0 +1,16 @@
1
+ import json
2
+ import os
3
+
4
+ from covjsonkit.api import Covjsonkit
5
+
6
+ current_dir = os.path.dirname(__file__)
7
+ timeseries = os.path.join(current_dir, "data/test_timeseries_coverage.json")
8
+ with open(timeseries, "r") as f:
9
+ timeseries = json.load(f)
10
+
11
+
12
+ cov = Covjsonkit().decode(timeseries)
13
+ ts = cov.to_geojson()
14
+ print(ts)
15
+ assert ts["type"] == "FeatureCollection"
16
+ assert len(ts["features"]) == 16
@@ -22,7 +22,7 @@ COMPOSITE_TWO_POINTS = {
22
22
 
23
23
  EXPECTED_REFORECAST_METADATA = {
24
24
  "class": "ce",
25
- "date": np.datetime64("2024-03-01"),
25
+ "date": "2024-03-01",
26
26
  "domain": "g",
27
27
  "expver": "4321",
28
28
  "levtype": "sfc",
@@ -130,7 +130,7 @@ class TestPositionFromPolytopeReforecast:
130
130
 
131
131
  shared_metadata = {
132
132
  "class": "ce",
133
- "date": np.datetime64("2024-03-01"),
133
+ "date": "2024-03-01",
134
134
  "Forecast date": "2025-07-14T06:00:00Z",
135
135
  "domain": "g",
136
136
  "expver": "4321",
@@ -168,7 +168,7 @@ class TestPositionFromPolytopeReforecast:
168
168
 
169
169
  shared_metadata = {
170
170
  "class": "ce",
171
- "date": np.datetime64("2024-03-01"),
171
+ "date": "2024-03-01",
172
172
  "domain": "g",
173
173
  "expver": "4321",
174
174
  "levtype": "sfc",
@@ -1,4 +1,8 @@
1
+ import json
2
+
1
3
  import numpy as np
4
+ import orjson
5
+ import pytest
2
6
  from conftest import chain, make_leaf, make_point, node, tip
3
7
  from polytope_feature.datacube.tensor_index_tree import TensorIndexTree
4
8
 
@@ -29,7 +33,7 @@ def hdate_branch(hdate, lat, lon, result):
29
33
 
30
34
  EXPECTED_HDATE_METADATA = {
31
35
  "class": "ce",
32
- "date": np.datetime64("2024-03-01"),
36
+ "date": "2024-03-01",
33
37
  "domain": "g",
34
38
  "expver": "4321",
35
39
  "levtype": "sfc",
@@ -199,12 +203,13 @@ class TestTimeseriesFromPolytope:
199
203
 
200
204
 
201
205
  class TestTimeseriesFromPolytopeReforecast:
202
- def test_single_point(self):
206
+ @pytest.mark.parametrize("date", [np.datetime64("2024-03-01"), np.datetime64("2024-03-01T00:00:00")])
207
+ def test_single_point(self, date):
203
208
  # 1 hdate (with time pre-merged by polytope-mars), 1 point
204
209
  tree = chain(
205
210
  TensorIndexTree(),
206
211
  node("class", ("ce",)),
207
- node("date", (np.datetime64("2024-03-01"),)),
212
+ node("date", (date,)),
208
213
  hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]),
209
214
  )
210
215
 
@@ -231,7 +236,11 @@ class TestTimeseriesFromPolytopeReforecast:
231
236
  }
232
237
  }
233
238
 
234
- assert cov["mars:metadata"] == {"Forecast date": "2025-07-14T06:00:00Z", **EXPECTED_HDATE_METADATA}
239
+ assert cov["mars:metadata"] == {
240
+ "Forecast date": "2025-07-14T06:00:00Z",
241
+ **EXPECTED_HDATE_METADATA,
242
+ "date": date.astype(str),
243
+ }
235
244
 
236
245
  def test_multiple_times(self):
237
246
  # 2 hdate values (pre-merged times from same day), 1 point → 2 coverages
@@ -453,3 +462,33 @@ class TestTimeseriesFromPolytopeReforecast:
453
462
  assert cov["domain"]["axes"]["t"]["values"] == t
454
463
  assert cov["ranges"]["dis06"]["values"] == vals
455
464
  assert cov["mars:metadata"] == {"Forecast date": fc_date, **EXPECTED_HDATE_METADATA}
465
+
466
+ @pytest.mark.parametrize(
467
+ "json_module,date", [(json, np.datetime64("2024-03-01")), (orjson, np.datetime64("2024-03-01T00:00:00"))]
468
+ )
469
+ def test_reforecast_covjson_is_json_serialisable(self, json_module, date):
470
+ """CoverageJSON produced by from_polytope_reforecast must be serialisable
471
+ with both stdlib json and orjson (i.e. no numpy.datetime64 left in the
472
+ mars:metadata dict — regression test for the date axis leak)."""
473
+ tree = chain(
474
+ TensorIndexTree(),
475
+ node("class", ("ce",)),
476
+ node("date", (date,)),
477
+ hdate_branch(np.datetime64("2025-07-14T06:00:00"), 51.5, 6.5, [42.17]),
478
+ )
479
+
480
+ covjson = Covjsonkit().encode("CoverageCollection", "PointSeries").from_polytope_reforecast(tree)
481
+
482
+ # stdlib json.dumps must not raise TypeError
483
+ serialised = json_module.dumps(covjson)
484
+
485
+ # The 'date' value in mars:metadata must be a plain string, not numpy.datetime64
486
+ for cov in covjson["coverages"]:
487
+ date_val = cov["mars:metadata"].get("date")
488
+ assert not isinstance(
489
+ date_val, np.datetime64
490
+ ), f"mars:metadata['date'] is still a numpy.datetime64: {date_val!r}"
491
+
492
+ # Round-trip through the JSON module to ensure it can be deserialised back to a dict
493
+ deserialised = json_module.loads(serialised)
494
+ assert deserialised == covjson
@@ -200,7 +200,7 @@ class TestVerticalProfileFromPolytopeReforecast:
200
200
 
201
201
  EXPECTED_REFORECAST_METADATA = {
202
202
  "class": "ce",
203
- "date": np.datetime64("2024-03-01"),
203
+ "date": "2024-03-01",
204
204
  "domain": "g",
205
205
  "expver": "4321",
206
206
  "levtype": "pl",
@@ -1 +0,0 @@
1
- __version__ = "0.2.16"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes