covjsonkit 0.2.22__tar.gz → 0.2.23__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 (114) hide show
  1. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/PKG-INFO +1 -1
  2. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/encoder.py +163 -142
  3. covjsonkit-0.2.23/covjsonkit/version.py +1 -0
  4. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit.egg-info/PKG-INFO +1 -1
  5. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit.egg-info/SOURCES.txt +1 -0
  6. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/pyproject.toml +1 -1
  7. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/conftest.py +61 -5
  8. covjsonkit-0.2.23/tests/test_encoder_merged_node_parity.py +111 -0
  9. covjsonkit-0.2.22/covjsonkit/version.py +0 -1
  10. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/.github/ci-hpc-config.yml +0 -0
  11. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/.github/workflows/ci.yaml +0 -0
  12. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/.github/workflows/label-public-pr.yml +0 -0
  13. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/.gitignore +0 -0
  14. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/.pre-commit-config.yaml +0 -0
  15. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/ACKNOWLEDGEMENTS.rst +0 -0
  16. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/LICENSE +0 -0
  17. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/MANIFEST.in +0 -0
  18. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/Makefile +0 -0
  19. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/README.md +0 -0
  20. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/Coverage.py +0 -0
  21. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/CoverageCollection.py +0 -0
  22. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/__init__.py +0 -0
  23. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/api.py +0 -0
  24. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/config.py +0 -0
  25. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/__init__.py +0 -0
  26. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/dwd/param.json +0 -0
  27. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/dwd/param_id.json +0 -0
  28. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/dwd/unit.json +0 -0
  29. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/ecmwf/param.json +0 -0
  30. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/ecmwf/param_id.json +0 -0
  31. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/data/ecmwf/unit.json +0 -0
  32. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/BoundingBox.py +0 -0
  33. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Circle.py +0 -0
  34. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Frame.py +0 -0
  35. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Grid.py +0 -0
  36. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Path.py +0 -0
  37. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Position.py +0 -0
  38. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Shapefile.py +0 -0
  39. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/TimeSeries.py +0 -0
  40. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/VerticalProfile.py +0 -0
  41. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/Wkt.py +0 -0
  42. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/__init__.py +0 -0
  43. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/decoder/decoder.py +0 -0
  44. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/BoundingBox.py +0 -0
  45. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Circle.py +0 -0
  46. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Frame.py +0 -0
  47. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Grid.py +0 -0
  48. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Path.py +0 -0
  49. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Position.py +0 -0
  50. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Shapefile.py +0 -0
  51. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/TimeSeries.py +0 -0
  52. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/VerticalProfile.py +0 -0
  53. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/Wkt.py +0 -0
  54. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/encoder/__init__.py +0 -0
  55. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/param_db.py +0 -0
  56. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit/utils.py +0 -0
  57. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit.egg-info/dependency_links.txt +0 -0
  58. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit.egg-info/not-zip-safe +0 -0
  59. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit.egg-info/requires.txt +0 -0
  60. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/covjsonkit.egg-info/top_level.txt +0 -0
  61. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/docs/images/ECMWF_logo.svg.png +0 -0
  62. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/docs/images/Logo_Destination_Earth_Colours.png +0 -0
  63. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/example_config.json +0 -0
  64. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/requirements.txt +0 -0
  65. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/scripts/generate_param_db.py +0 -0
  66. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/setup.cfg +0 -0
  67. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/setup.py +0 -0
  68. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_coverage1.json +0 -0
  69. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_coverage2.json +0 -0
  70. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_geotiff.json +0 -0
  71. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_multipoint_coverage.json +0 -0
  72. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_path_coverage.json +0 -0
  73. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_timeseries_coverage.json +0 -0
  74. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_timeseries_param_t.json +0 -0
  75. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_timeseries_xyz_coverage.json +0 -0
  76. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_verticalprofile_coverage.json +0 -0
  77. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_verticalprofile_param_t.json +0 -0
  78. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/data/test_verticalprofile_xyz_coverage.json +0 -0
  79. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/geojsontest.py +0 -0
  80. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/requirements_test.txt +0 -0
  81. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_bounding_box.py +0 -0
  82. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_circle.py +0 -0
  83. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_frame.py +0 -0
  84. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_grid.py +0 -0
  85. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_position.py +0 -0
  86. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_shapefile.py +0 -0
  87. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_time_series.py +0 -0
  88. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_vertical_profile.py +0 -0
  89. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_decoder_wkt.py +0 -0
  90. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_bounding_box.py +0 -0
  91. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_bounding_box_from_polytope.py +0 -0
  92. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_circle_from_polytope.py +0 -0
  93. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_frame.py +0 -0
  94. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_frame_from_polytope.py +0 -0
  95. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_grid.py +0 -0
  96. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_grid_from_polytope.py +0 -0
  97. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_path.py +0 -0
  98. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_path_from_polytope.py +0 -0
  99. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_position_from_polytope.py +0 -0
  100. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_shapefile.py +0 -0
  101. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_shapefile_from_polytope.py +0 -0
  102. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_time_series.py +0 -0
  103. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_time_series_from_polytope.py +0 -0
  104. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_vertical_profile_from_polytope.py +0 -0
  105. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_wkt.py +0 -0
  106. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_encoder_wkt_from_polytope.py +0 -0
  107. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_geojson.py +0 -0
  108. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_geotiff.py +0 -0
  109. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_util_merge.py +0 -0
  110. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_xarray_multipoint.py +0 -0
  111. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_xarray_path.py +0 -0
  112. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_xarray_ts.py +0 -0
  113. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tests/test_xarray_vp.py +0 -0
  114. {covjsonkit-0.2.22 → covjsonkit-0.2.23}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: covjsonkit
3
- Version: 0.2.22
3
+ Version: 0.2.23
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
@@ -12,6 +12,25 @@ from covjson_pydantic.domain import DomainType
12
12
 
13
13
  from covjsonkit.param_db import get_param_ids, get_params, get_units
14
14
 
15
+ try:
16
+ # Polytope compacts unstructured-grid (e.g. ICON, Lambert LAM) leaves into a single
17
+ # MergedTensorIndexNode holding axes=(lat_axis, lon_axis) and values=(lat, lon).
18
+ from polytope_feature.datacube.tensor_index_tree import MergedTensorIndexNode
19
+ except ImportError: # older polytope without merged nodes
20
+ MergedTensorIndexNode = None
21
+
22
+
23
+ def is_merged_node(node) -> bool:
24
+ """True if ``node`` is a polytope ``MergedTensorIndexNode`` (a compacted lat/lon leaf).
25
+
26
+ Such nodes carry ``axes=(lat_axis, lon_axis)`` and ``values=(lat, lon)`` for a single
27
+ spatial point, and are always leaves. Falls back to duck-typing (``.axes`` present)
28
+ if the polytope import was unavailable.
29
+ """
30
+ if MergedTensorIndexNode is not None:
31
+ return isinstance(node, MergedTensorIndexNode)
32
+ return hasattr(node, "axes") and getattr(node, "axes", None) is not None
33
+
15
34
 
16
35
  def timedelta_to_step_string(td: timedelta) -> str:
17
36
  """
@@ -383,32 +402,15 @@ class Encoder(ABC):
383
402
  for value in tree_values:
384
403
  coords[dates]["composite"].append([lat, value])
385
404
 
386
- if len(tree.children) != 0:
387
- for child in tree.children:
388
- handle_non_leaf_node(child)
389
- result = handle_specific_axes(child)
390
- if result is not None:
391
- if child.axis.name == "latitude":
392
- fields["lat"] = result
393
- elif child.axis.name == "levelist":
394
- fields["levels"] = result
395
- if "l" in fields:
396
- fields["l"].extend(result)
397
- elif child.axis.name == "param":
398
- fields["param"] = result
399
- elif child.axis.name in [date_key, "time"]:
400
- fields["dates"].extend(result)
401
- elif child.axis.name == "number":
402
- fields["number"] = result
403
- elif child.axis.name == "step":
404
- fields["step"] = result
405
- if "s" in fields:
406
- fields["s"].extend(result)
405
+ def emit_leaf(lat, lon_values, result):
406
+ """Emit one spatial leaf: append [lat, lon] composite coords and slice results.
407
407
 
408
- self.walk_tree(child, fields, coords, mars_metadata, range_dict, date_key=date_key)
409
- else:
410
- tree.values = [float(val) for val in tree.values]
411
- if all(val is None for val in tree.result):
408
+ Shared by the legacy longitude-leaf path (``lat`` from the parent latitude
409
+ node, ``lon_values`` = the leaf's list of longitudes) and the compacted
410
+ ``MergedTensorIndexNode`` path (single point: ``lon_values`` = [lon]).
411
+ """
412
+ lon_values = [float(val) for val in lon_values]
413
+ if all(val is None for val in result):
412
414
  fields["dates"] = fields["dates"][:-1]
413
415
  for date in fields["dates"]:
414
416
  for level in fields["levels"]:
@@ -419,13 +421,13 @@ class Encoder(ABC):
419
421
  if key in range_dict:
420
422
  del range_dict[key]
421
423
  else:
422
- tree.result = [float(val) if val is not None else val for val in tree.result]
423
- level_len = len(tree.result) / len(fields["levels"])
424
+ result = [float(val) if val is not None else val for val in result]
425
+ level_len = len(result) / len(fields["levels"])
424
426
  num_len = level_len / len(fields["number"])
425
427
  para_len = num_len / len(fields["param"])
426
428
  step_len = para_len / len(fields["step"])
427
429
 
428
- append_composite_coords(fields["dates"][-1], tree.values, fields["lat"], coords)
430
+ append_composite_coords(fields["dates"][-1], lon_values, lat, coords)
429
431
 
430
432
  for l, level in enumerate(fields["levels"]): # noqa: E741
431
433
  for i, num in enumerate(fields["number"]):
@@ -437,7 +439,37 @@ class Encoder(ABC):
437
439
  key = create_composite_key(fields["dates"][-1], level, num, para, s)
438
440
  if key not in range_dict:
439
441
  range_dict[key] = []
440
- range_dict[key].extend(tree.result[start_index:end_index])
442
+ range_dict[key].extend(result[start_index:end_index])
443
+
444
+ if len(tree.children) != 0:
445
+ for child in tree.children:
446
+ # Compacted unstructured leaf: values=(lat, lon), own result. Emit directly.
447
+ if is_merged_node(child):
448
+ emit_leaf(child.values[0], [child.values[1]], child.result)
449
+ continue
450
+ handle_non_leaf_node(child)
451
+ result = handle_specific_axes(child)
452
+ if result is not None:
453
+ if child.axis.name == "latitude":
454
+ fields["lat"] = result
455
+ elif child.axis.name == "levelist":
456
+ fields["levels"] = result
457
+ if "l" in fields:
458
+ fields["l"].extend(result)
459
+ elif child.axis.name == "param":
460
+ fields["param"] = result
461
+ elif child.axis.name in [date_key, "time"]:
462
+ fields["dates"].extend(result)
463
+ elif child.axis.name == "number":
464
+ fields["number"] = result
465
+ elif child.axis.name == "step":
466
+ fields["step"] = result
467
+ if "s" in fields:
468
+ fields["s"].extend(result)
469
+
470
+ self.walk_tree(child, fields, coords, mars_metadata, range_dict, date_key=date_key)
471
+ else:
472
+ emit_leaf(fields["lat"], tree.values, tree.result)
441
473
 
442
474
  def walk_tree_step(self, tree, fields, coords, mars_metadata, range_dict):
443
475
  def create_composite_key_step(date, level, num, para):
@@ -496,34 +528,10 @@ class Encoder(ABC):
496
528
  for value in tree_values:
497
529
  coords[dates]["composite"].append([lat, value])
498
530
 
499
- if len(tree.children) != 0:
500
- for child in tree.children:
501
- handle_non_leaf_node_step(child)
502
- result = handle_specific_axes_step(child)
503
- if result is not None:
504
- if child.axis.name == "latitude":
505
- fields["lat"] = result
506
- elif child.axis.name == "levelist":
507
- fields["levels"] = result
508
- if "l" in fields:
509
- fields["l"].extend(result)
510
- elif child.axis.name == "param":
511
- fields["param"] = result
512
- elif child.axis.name in ["date"]:
513
- fields["dates"].extend(result)
514
- elif child.axis.name == "number":
515
- fields["number"] = result
516
- elif child.axis.name == "step":
517
- fields["step"] = result
518
- if "s" in fields:
519
- fields["s"].extend(result)
520
- elif child.axis.name == "time":
521
- fields["times"].extend(result)
522
-
523
- self.walk_tree_step(child, fields, coords, mars_metadata, range_dict)
524
- else:
525
- tree.values = [float(val) for val in tree.values]
526
- if all(val is None for val in tree.result):
531
+ def emit_leaf_step(lat, lon_values, result):
532
+ """Emit one spatial leaf for the step walker (shared by legacy and merged)."""
533
+ lon_values = [float(val) for val in lon_values]
534
+ if all(val is None for val in result):
527
535
  fields["dates"] = fields["dates"][:-1]
528
536
  for date in fields["dates"]:
529
537
  for level in fields["levels"]:
@@ -534,61 +542,61 @@ class Encoder(ABC):
534
542
  if key in range_dict:
535
543
  del range_dict[key]
536
544
  else:
537
- tree.result = [float(val) if val is not None else val for val in tree.result]
538
- date_len = len(tree.result) / len(fields["dates"])
545
+ result = [float(val) if val is not None else val for val in result]
546
+ date_len = len(result) / len(fields["dates"])
539
547
  level_len = date_len / len(fields["levels"])
540
548
  para_len = level_len / len(fields["param"])
541
- # time_len = para_len / len(fields["times"])
542
- # coords_len = len(tree.values)
543
549
 
544
550
  for date in fields["dates"]:
545
- append_composite_coords_step(date, tree.values, fields["lat"], coords)
546
- """
547
- for ti, _ in enumerate(fields["times"]):
548
- for d, date in enumerate(fields["dates"]):
549
- for l, level in enumerate(fields["levels"]): # noqa: E741
550
- for i, num in enumerate(fields["number"]):
551
- for j, para in enumerate(fields["param"]):
552
- # for k, t in enumerate(fields["times"]):
553
- # start_index, end_index = calculate_index_bounds_step(
554
- # level_len, num_len, para_len, time_len, l, i, j, k
555
- # )
556
- key = create_composite_key_step(date, level, num, para)
557
- if key not in range_dict:
558
- range_dict[key] = []
559
- # range_dict[key].extend(tree.result[start_index:end_index])
560
- # print(d, date_len,j, para_len)
561
- # print(d*date_len+j*para_len)
562
- # print(int(d*date_len+j*para_len+len(fields["times"])))
563
- # print(tree.result[int(d*date_len+j*para_len+len)])
564
- #print(tree.result)
565
- range_dict[key].append(#tree.result
566
- tree.result[
567
- int(d * date_len + j * para_len + ti * time_len) : int(
568
- d * date_len + j * para_len + ti * time_len + len(tree.values)
569
- )
570
- ]
571
- )
572
- """
551
+ append_composite_coords_step(date, lon_values, lat, coords)
552
+
573
553
  for d, date in enumerate(fields["dates"]):
574
554
  for l, level in enumerate(fields["levels"]): # noqa: E741
575
555
  for i, num in enumerate(fields["number"]):
576
556
  for j, para in enumerate(fields["param"]):
577
- # for k, t in enumerate(fields["times"]):
578
- # start_index, end_index = calculate_index_bounds_step(
579
- # level_len, num_len, para_len, time_len, l, i, j, k
580
- # )
581
557
  key = create_composite_key_step(date, level, num, para)
582
558
  if key not in range_dict:
583
559
  range_dict[key] = []
584
- range_dict[key].append( # tree.result
585
- tree.result[
560
+ range_dict[key].append(
561
+ result[
586
562
  int(d * date_len + l * level_len + j * para_len) : int(
587
563
  d * date_len + l * level_len + j * para_len + len(fields["times"])
588
564
  )
589
565
  ]
590
566
  )
591
567
 
568
+ if len(tree.children) != 0:
569
+ for child in tree.children:
570
+ # Compacted unstructured leaf: values=(lat, lon), own result. Emit directly.
571
+ if is_merged_node(child):
572
+ emit_leaf_step(child.values[0], [child.values[1]], child.result)
573
+ continue
574
+ handle_non_leaf_node_step(child)
575
+ result = handle_specific_axes_step(child)
576
+ if result is not None:
577
+ if child.axis.name == "latitude":
578
+ fields["lat"] = result
579
+ elif child.axis.name == "levelist":
580
+ fields["levels"] = result
581
+ if "l" in fields:
582
+ fields["l"].extend(result)
583
+ elif child.axis.name == "param":
584
+ fields["param"] = result
585
+ elif child.axis.name in ["date"]:
586
+ fields["dates"].extend(result)
587
+ elif child.axis.name == "number":
588
+ fields["number"] = result
589
+ elif child.axis.name == "step":
590
+ fields["step"] = result
591
+ if "s" in fields:
592
+ fields["s"].extend(result)
593
+ elif child.axis.name == "time":
594
+ fields["times"].extend(result)
595
+
596
+ self.walk_tree_step(child, fields, coords, mars_metadata, range_dict)
597
+ else:
598
+ emit_leaf_step(fields["lat"], tree.values, tree.result)
599
+
592
600
  def walk_tree_month(self, tree, fields, coords, mars_metadata, range_dict, _ctx=None):
593
601
  """Walk the result tree for monthly-mean streams (e.g. clmn).
594
602
 
@@ -640,47 +648,13 @@ class Encoder(ABC):
640
648
  for value in tree_values:
641
649
  coords[date_key]["composite"].append([lat, value])
642
650
 
643
- if len(tree.children) != 0:
644
- for child in tree.children:
645
- handle_non_leaf_node_month(child)
646
- result = handle_specific_axes_month(child)
647
-
648
- # Build a child context that inherits the current year/month
649
- child_ctx = dict(_ctx)
651
+ def emit_leaf_month(lat, lon_values, result):
652
+ """Emit one spatial leaf for the month walker (shared by legacy and merged).
650
653
 
651
- if result is not None:
652
- if child.axis.name == "latitude":
653
- fields["lat"] = result
654
- elif child.axis.name == "levelist":
655
- fields["levels"] = result
656
- if "l" in fields:
657
- fields["l"].extend(result)
658
- elif child.axis.name == "param":
659
- fields["param"] = result
660
- elif child.axis.name == "year":
661
- fields["years"] = list(result) if fields.get("years") == [] else fields["years"]
662
- child_ctx["years"] = result
663
- child_ctx["_axis_order"] = _ctx.get("_axis_order", []) + ["year"]
664
- # If month is already fixed in context, register dates now.
665
- if "months" in _ctx:
666
- for y in result:
667
- for m in _ctx["months"]:
668
- _register_date_key(_year_month_key(y, m))
669
- elif child.axis.name == "month":
670
- fields["months"] = list(result) if fields.get("months") == [] else fields["months"]
671
- child_ctx["months"] = result
672
- # If year is already fixed in context, register dates now.
673
- if "years" in _ctx:
674
- for y in _ctx["years"]:
675
- for m in result:
676
- _register_date_key(_year_month_key(y, m))
677
- # Track that month is the inner axis relative to year
678
- child_ctx["_axis_order"] = _ctx.get("_axis_order", []) + ["month"]
679
- elif child.axis.name == "number":
680
- fields["number"] = result
681
-
682
- self.walk_tree_month(child, fields, coords, mars_metadata, range_dict, _ctx=child_ctx)
683
- else:
654
+ ``lat`` is the latitude for this leaf, ``lon_values`` the leaf's list of
655
+ longitudes (length 1 for a compacted ``MergedTensorIndexNode``), and
656
+ ``result`` its flat value array.
657
+ """
684
658
  # Leaf node — ensure all (year, month) combinations from context are registered.
685
659
  ctx_years = _ctx.get("years", fields.get("years", []))
686
660
  ctx_months = _ctx.get("months", fields.get("months", []))
@@ -708,8 +682,8 @@ class Encoder(ABC):
708
682
  else:
709
683
  leaf_dates = fields["dates"]
710
684
 
711
- tree.values = [float(val) for val in tree.values]
712
- if all(val is None for val in tree.result):
685
+ lon_values = [float(val) for val in lon_values]
686
+ if all(val is None for val in result):
713
687
  # Remove date entries for this leaf that produced no data.
714
688
  for key in leaf_dates:
715
689
  if key in fields["dates"]:
@@ -721,20 +695,20 @@ class Encoder(ABC):
721
695
  if rkey in range_dict:
722
696
  del range_dict[rkey]
723
697
  else:
724
- tree.result = [float(val) if val is not None else val for val in tree.result]
698
+ result = [float(val) if val is not None else val for val in result]
725
699
 
726
700
  n_dates = len(leaf_dates)
727
701
  n_levels = len(fields["levels"])
728
702
  n_params = len(fields["param"])
729
703
 
730
- date_len = len(tree.result) / n_dates if n_dates else len(tree.result)
704
+ date_len = len(result) / n_dates if n_dates else len(result)
731
705
  level_len = date_len / n_levels if n_levels else date_len
732
706
  para_len = level_len / n_params if n_params else level_len
733
707
 
734
708
  # Append this leaf's longitude values to composite coords for
735
709
  # every date key in scope.
736
710
  for date in leaf_dates:
737
- append_composite_coords_month(date, tree.values, fields["lat"])
711
+ append_composite_coords_month(date, lon_values, lat)
738
712
 
739
713
  for d, date in enumerate(leaf_dates):
740
714
  for l, level in enumerate(fields["levels"]): # noqa: E741
@@ -744,8 +718,55 @@ class Encoder(ABC):
744
718
  if key not in range_dict:
745
719
  range_dict[key] = []
746
720
  start = int(d * date_len + l * level_len + j * para_len)
747
- end = int(start + len(tree.values))
748
- range_dict[key].append(tree.result[start:end])
721
+ end = int(start + len(lon_values))
722
+ range_dict[key].append(result[start:end])
723
+
724
+ if len(tree.children) != 0:
725
+ for child in tree.children:
726
+ # Compacted unstructured leaf: values=(lat, lon), own result. Emit directly.
727
+ if is_merged_node(child):
728
+ emit_leaf_month(child.values[0], [child.values[1]], child.result)
729
+ continue
730
+ handle_non_leaf_node_month(child)
731
+ result = handle_specific_axes_month(child)
732
+
733
+ # Build a child context that inherits the current year/month
734
+ child_ctx = dict(_ctx)
735
+
736
+ if result is not None:
737
+ if child.axis.name == "latitude":
738
+ fields["lat"] = result
739
+ elif child.axis.name == "levelist":
740
+ fields["levels"] = result
741
+ if "l" in fields:
742
+ fields["l"].extend(result)
743
+ elif child.axis.name == "param":
744
+ fields["param"] = result
745
+ elif child.axis.name == "year":
746
+ fields["years"] = list(result) if fields.get("years") == [] else fields["years"]
747
+ child_ctx["years"] = result
748
+ child_ctx["_axis_order"] = _ctx.get("_axis_order", []) + ["year"]
749
+ # If month is already fixed in context, register dates now.
750
+ if "months" in _ctx:
751
+ for y in result:
752
+ for m in _ctx["months"]:
753
+ _register_date_key(_year_month_key(y, m))
754
+ elif child.axis.name == "month":
755
+ fields["months"] = list(result) if fields.get("months") == [] else fields["months"]
756
+ child_ctx["months"] = result
757
+ # If year is already fixed in context, register dates now.
758
+ if "years" in _ctx:
759
+ for y in _ctx["years"]:
760
+ for m in result:
761
+ _register_date_key(_year_month_key(y, m))
762
+ # Track that month is the inner axis relative to year
763
+ child_ctx["_axis_order"] = _ctx.get("_axis_order", []) + ["month"]
764
+ elif child.axis.name == "number":
765
+ fields["number"] = result
766
+
767
+ self.walk_tree_month(child, fields, coords, mars_metadata, range_dict, _ctx=child_ctx)
768
+ else:
769
+ emit_leaf_month(fields["lat"], tree.values, tree.result)
749
770
 
750
771
  @abstractmethod
751
772
  def add_coverage(self, mars_metadata, coords, values):
@@ -0,0 +1 @@
1
+ __version__ = "0.2.23"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: covjsonkit
3
- Version: 0.2.22
3
+ Version: 0.2.23
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
@@ -80,6 +80,7 @@ tests/test_encoder_frame.py
80
80
  tests/test_encoder_frame_from_polytope.py
81
81
  tests/test_encoder_grid.py
82
82
  tests/test_encoder_grid_from_polytope.py
83
+ tests/test_encoder_merged_node_parity.py
83
84
  tests/test_encoder_path.py
84
85
  tests/test_encoder_path_from_polytope.py
85
86
  tests/test_encoder_position_from_polytope.py
@@ -22,7 +22,7 @@ markers = ["data: uses test data (deselect with '-m \"not data\"')",]
22
22
 
23
23
  [project]
24
24
  name = "covjsonkit"
25
- version = "0.2.22"
25
+ version = "0.2.23"
26
26
  dependencies = [
27
27
  "pandas<3",
28
28
  "orjson",
@@ -2,6 +2,15 @@ import numpy as np
2
2
  from polytope_feature.datacube.datacube_axis import IntDatacubeAxis
3
3
  from polytope_feature.datacube.tensor_index_tree import TensorIndexTree
4
4
 
5
+ try:
6
+ # Only available on polytope versions that support compacted unstructured
7
+ # (ICON, Lambert LAM) results. Absent on released polytope, in which case
8
+ # the merged-node fixtures/tests below are skipped rather than breaking
9
+ # collection of the whole test suite.
10
+ from polytope_feature.datacube.tensor_index_tree import MergedTensorIndexNode
11
+ except ImportError:
12
+ MergedTensorIndexNode = None
13
+
5
14
  # -- Shared constants for reforecast tests --
6
15
 
7
16
  REFORECAST_METADATA_BASE = {
@@ -61,14 +70,36 @@ def make_point(lat, lon, result):
61
70
  return lat_n
62
71
 
63
72
 
64
- def forecast_tree(points, param="167", step=(0,), date=np.datetime64("2025-01-01T00:00:00")):
73
+ def make_merged_point(lat, lon, result):
74
+ """Create a compacted MergedTensorIndexNode for a single spatial point.
75
+
76
+ This is the unstructured-grid equivalent of :func:`make_point`: instead of a
77
+ latitude node with a longitude-leaf child, the (lat, lon) pair is compacted
78
+ into a single leaf node carrying its own ``result``.
79
+ """
80
+ if MergedTensorIndexNode is None:
81
+ raise RuntimeError(
82
+ "MergedTensorIndexNode is not available in this polytope build; " "merged-node tests should be skipped."
83
+ )
84
+ lat_ax = IntDatacubeAxis()
85
+ lat_ax.name = "latitude"
86
+ lon_ax = IntDatacubeAxis()
87
+ lon_ax.name = "longitude"
88
+ merged = MergedTensorIndexNode(axes=(lat_ax, lon_ax), values=(lat, lon))
89
+ merged.result = [np.float64(r) for r in result]
90
+ return merged
91
+
92
+
93
+ def forecast_tree(points, param="167", step=(0,), date=np.datetime64("2025-01-01T00:00:00"), point_factory=make_point):
65
94
  """Build a standard forecast TensorIndexTree with the given spatial points.
66
95
 
67
96
  Args:
68
- points: list of (lat, lon, result_list) tuples, passed to make_point().
97
+ points: list of (lat, lon, result_list) tuples, passed to point_factory().
69
98
  param: MARS parameter code.
70
99
  step: tuple of step values.
71
100
  date: forecast date.
101
+ point_factory: callable(lat, lon, result) -> node. Use make_merged_point
102
+ to build a compacted unstructured (MergedTensorIndexNode) tree.
72
103
  """
73
104
  tree = chain(
74
105
  TensorIndexTree(),
@@ -84,11 +115,36 @@ def forecast_tree(points, param="167", step=(0,), date=np.datetime64("2025-01-01
84
115
  )
85
116
  parent = tip(tree)
86
117
  for lat, lon, result in points:
87
- parent.add_child(make_point(lat, lon, result))
118
+ parent.add_child(point_factory(lat, lon, result))
119
+ return tree
120
+
121
+
122
+ def month_tree(points, param="167", years=(2020, 2021), months=(1,), point_factory=make_point):
123
+ """Build a monthly-mean tree (year/month axes) for the walk_tree_month path.
124
+
125
+ Args:
126
+ points: list of (lat, lon, result_list) tuples, passed to point_factory().
127
+ param: MARS parameter code.
128
+ years: tuple of year values.
129
+ months: tuple of month values.
130
+ point_factory: callable(lat, lon, result) -> node. Use make_merged_point
131
+ to build a compacted unstructured (MergedTensorIndexNode) tree.
132
+ """
133
+ tree = chain(
134
+ TensorIndexTree(),
135
+ node("class", ("od",)),
136
+ node("levtype", ("sfc",)),
137
+ node("param", (param,)),
138
+ node("year", years),
139
+ node("month", months),
140
+ )
141
+ parent = tip(tree)
142
+ for lat, lon, result in points:
143
+ parent.add_child(point_factory(lat, lon, result))
88
144
  return tree
89
145
 
90
146
 
91
- def reforecast_branch(hdate, points, param="167", step=(0,)):
147
+ def reforecast_branch(hdate, points, param="167", step=(0,), point_factory=make_point):
92
148
  """Build a reforecast branch rooted at an hdate node.
93
149
 
94
150
  Attaches spatial points at the leaf. Caller is responsible for
@@ -106,7 +162,7 @@ def reforecast_branch(hdate, points, param="167", step=(0,)):
106
162
  )
107
163
  parent = tip(branch)
108
164
  for lat, lon, result in points:
109
- parent.add_child(make_point(lat, lon, result))
165
+ parent.add_child(point_factory(lat, lon, result))
110
166
  return branch
111
167
 
112
168
 
@@ -0,0 +1,111 @@
1
+ """Parity tests for compacted unstructured-grid results.
2
+
3
+ Polytope emits ``MergedTensorIndexNode`` leaves for unstructured grids (ICON,
4
+ Lambert LAM), compacting each (latitude, longitude) point and its result into a
5
+ single leaf instead of a latitude -> longitude-leaf subtree. These tests assert
6
+ that covjsonkit produces byte-identical CoverageJSON whether the source tree
7
+ uses the legacy layout (``make_point``) or the compacted layout
8
+ (``make_merged_point``), exercising all tree walkers.
9
+ """
10
+
11
+ import numpy as np
12
+ import pytest
13
+ from conftest import (
14
+ MergedTensorIndexNode,
15
+ chain,
16
+ forecast_tree,
17
+ make_merged_point,
18
+ make_point,
19
+ month_tree,
20
+ node,
21
+ reforecast_branch,
22
+ reforecast_tree,
23
+ tip,
24
+ )
25
+ from polytope_feature.datacube.tensor_index_tree import TensorIndexTree
26
+
27
+ from covjsonkit.api import Covjsonkit
28
+
29
+ # These tests require a polytope build with compacted unstructured-grid support.
30
+ # On released polytope (no MergedTensorIndexNode) they are skipped entirely.
31
+ pytestmark = pytest.mark.skipif(
32
+ MergedTensorIndexNode is None,
33
+ reason="polytope build lacks MergedTensorIndexNode (compacted unstructured support)",
34
+ )
35
+
36
+ TWO_POINTS = [(48.0, 11.0, [264.9]), (50.0, 12.0, [265.1])]
37
+
38
+
39
+ def _encode(tree, feature="BoundingBox", reforecast=False):
40
+ api = Covjsonkit().encode("CoverageCollection", feature)
41
+ if reforecast:
42
+ return api.from_polytope_reforecast(tree)
43
+ return api.from_polytope(tree)
44
+
45
+
46
+ def _encode_month(tree, feature="BoundingBox"):
47
+ return Covjsonkit().encode("CoverageCollection", feature).from_polytope_month(tree)
48
+
49
+
50
+ class TestMergedNodeParity:
51
+ def test_walk_tree_single_date_single_step(self):
52
+ legacy = _encode(forecast_tree(TWO_POINTS, point_factory=make_point))
53
+ merged = _encode(forecast_tree(TWO_POINTS, point_factory=make_merged_point))
54
+ assert merged == legacy
55
+
56
+ def test_walk_tree_multi_step(self):
57
+ points = [(48.0, 11.0, [264.9, 270.1]), (50.0, 12.0, [265.1, 271.3])]
58
+ legacy = _encode(forecast_tree(points, step=(0, 6), point_factory=make_point))
59
+ merged = _encode(forecast_tree(points, step=(0, 6), point_factory=make_merged_point))
60
+ assert merged == legacy
61
+
62
+ def test_walk_tree_two_dates_two_steps(self):
63
+ def build(point_factory):
64
+ tree = chain(TensorIndexTree(), node("class", ("od",)))
65
+ cls = tip(tree)
66
+ for date_val, vals in [
67
+ (np.datetime64("2025-01-01T00:00:00"), [[264.9, 270.1], [265.1, 271.3]]),
68
+ (np.datetime64("2025-01-02T00:00:00"), [[266.0, 272.0], [267.0, 273.0]]),
69
+ ]:
70
+ branch = chain(
71
+ node("date", (date_val,)),
72
+ node("domain", ("g",)),
73
+ node("expver", ("0001",)),
74
+ node("levtype", ("sfc",)),
75
+ node("param", ("167",)),
76
+ node("step", (0, 6)),
77
+ node("stream", ("oper",)),
78
+ node("type", ("fc",)),
79
+ )
80
+ fc = tip(branch)
81
+ fc.add_child(point_factory(48.0, 11.0, vals[0]))
82
+ fc.add_child(point_factory(50.0, 12.0, vals[1]))
83
+ cls.add_child(branch)
84
+ return tree
85
+
86
+ assert _encode(build(make_merged_point)) == _encode(build(make_point))
87
+
88
+ def test_reforecast_walker(self):
89
+ def build(point_factory):
90
+ return reforecast_tree(
91
+ [
92
+ reforecast_branch(np.datetime64("2025-07-14T06:00:00"), TWO_POINTS, point_factory=point_factory),
93
+ reforecast_branch(
94
+ np.datetime64("2025-07-15T06:00:00"),
95
+ [(48.0, 11.0, [266.0]), (50.0, 12.0, [267.0])],
96
+ point_factory=point_factory,
97
+ ),
98
+ ]
99
+ )
100
+
101
+ legacy = _encode(build(make_point), reforecast=True)
102
+ merged = _encode(build(make_merged_point), reforecast=True)
103
+ assert merged == legacy
104
+
105
+ def test_walk_tree_month(self):
106
+ # Monthly-mean (year/month axes) path: two years x one month, two points.
107
+ points = [(48.0, 11.0, [264.9, 265.9]), (50.0, 12.0, [266.1, 267.1])]
108
+ legacy = _encode_month(month_tree(points, point_factory=make_point))
109
+ merged = _encode_month(month_tree(points, point_factory=make_merged_point))
110
+ assert len(legacy["coverages"]) == 2
111
+ assert merged == legacy
@@ -1 +0,0 @@
1
- __version__ = "0.2.22"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes