firecube 0.1.4__py3-none-any.whl

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 (197) hide show
  1. firecube/__init__.py +23 -0
  2. firecube/__main__.py +20 -0
  3. firecube/_version.py +46 -0
  4. firecube/cli/_audience.py +135 -0
  5. firecube/cli/_command_schemas.py +112 -0
  6. firecube/cli/_ctx.py +85 -0
  7. firecube/cli/_errors.py +115 -0
  8. firecube/cli/_formatter.py +195 -0
  9. firecube/cli/_product.py +47 -0
  10. firecube/cli/_rename_hints.py +123 -0
  11. firecube/cli/_shared_options.py +247 -0
  12. firecube/cli/_slot_env.py +102 -0
  13. firecube/cli/_slot_planning.py +95 -0
  14. firecube/cli/_typed_options.py +181 -0
  15. firecube/cli/_uri_policy.py +110 -0
  16. firecube/cli/advise.py +321 -0
  17. firecube/cli/archive.py +870 -0
  18. firecube/cli/catalog.py +196 -0
  19. firecube/cli/chunks/__init__.py +74 -0
  20. firecube/cli/chunks/_claims.py +229 -0
  21. firecube/cli/chunks/_common.py +84 -0
  22. firecube/cli/chunks/_delete.py +376 -0
  23. firecube/cli/chunks/_list.py +334 -0
  24. firecube/cli/chunks/_manager.py +161 -0
  25. firecube/cli/chunks/_runs.py +294 -0
  26. firecube/cli/chunks/_snapshots.py +165 -0
  27. firecube/cli/completion.py +73 -0
  28. firecube/cli/main.py +866 -0
  29. firecube/cli/parquet.py +289 -0
  30. firecube/cli/plugins/__init__.py +28 -0
  31. firecube/cli/plugins/commands.py +262 -0
  32. firecube/cli/plugins/introspect.py +304 -0
  33. firecube/cli/plugins/mgmt.py +198 -0
  34. firecube/cli/plugins/registry.py +132 -0
  35. firecube/cli/zarr.py +1043 -0
  36. firecube/core/__init__.py +14 -0
  37. firecube/core/api.py +88 -0
  38. firecube/core/cf/__init__.py +23 -0
  39. firecube/core/cf/check_ids.py +126 -0
  40. firecube/core/cf/report.py +57 -0
  41. firecube/core/cf/validator.py +313 -0
  42. firecube/core/config.py +307 -0
  43. firecube/core/controlplane/__init__.py +47 -0
  44. firecube/core/controlplane/_event_processor.py +136 -0
  45. firecube/core/controlplane/_helpers.py +114 -0
  46. firecube/core/controlplane/_projection.py +253 -0
  47. firecube/core/controlplane/_snapshot.py +215 -0
  48. firecube/core/controlplane/_wal_reader.py +402 -0
  49. firecube/core/controlplane/_wal_writer.py +589 -0
  50. firecube/core/controlplane/claims.py +294 -0
  51. firecube/core/controlplane/deletion.py +704 -0
  52. firecube/core/controlplane/events.py +219 -0
  53. firecube/core/controlplane/manager.py +990 -0
  54. firecube/core/controlplane/metrics.py +97 -0
  55. firecube/core/controlplane/repo.py +804 -0
  56. firecube/core/controlplane/repo_utils.py +73 -0
  57. firecube/core/controlplane/time_dim.py +111 -0
  58. firecube/core/controlplane/types.py +445 -0
  59. firecube/core/credentials.py +36 -0
  60. firecube/core/duckdb/__init__.py +27 -0
  61. firecube/core/duckdb/bridge.py +82 -0
  62. firecube/core/duckdb/io.py +30 -0
  63. firecube/core/errors.py +74 -0
  64. firecube/core/filesystem/__init__.py +60 -0
  65. firecube/core/filesystem/_obstore_compat.py +60 -0
  66. firecube/core/filesystem/fsspec_backend.py +308 -0
  67. firecube/core/filesystem/instrumentation.py +260 -0
  68. firecube/core/filesystem/obstore_backend.py +406 -0
  69. firecube/core/filesystem/ops.py +412 -0
  70. firecube/core/filesystem/protocol.py +130 -0
  71. firecube/core/filesystem/store_factory.py +149 -0
  72. firecube/core/formats/__init__.py +46 -0
  73. firecube/core/formats/_iso.py +84 -0
  74. firecube/core/formats/discovery.py +165 -0
  75. firecube/core/formats/hdf5.py +137 -0
  76. firecube/core/formats/netcdf.py +226 -0
  77. firecube/core/formats/zip.py +192 -0
  78. firecube/core/intake.py +530 -0
  79. firecube/core/observability/__init__.py +72 -0
  80. firecube/core/observability/_state.py +24 -0
  81. firecube/core/observability/logging.py +147 -0
  82. firecube/core/observability/metrics.py +230 -0
  83. firecube/core/observability/telemetry/__init__.py +87 -0
  84. firecube/core/observability/telemetry/pushgateway.py +339 -0
  85. firecube/core/observability/telemetry/sinks.py +155 -0
  86. firecube/core/observability/tracing.py +126 -0
  87. firecube/core/product/__init__.py +45 -0
  88. firecube/core/product/identity.py +76 -0
  89. firecube/core/product/resolver.py +121 -0
  90. firecube/core/product/target.py +120 -0
  91. firecube/core/runtime.py +187 -0
  92. firecube/core/slot_index.py +205 -0
  93. firecube/core/storage/__init__.py +45 -0
  94. firecube/core/storage/binding.py +39 -0
  95. firecube/core/storage/cache_key.py +40 -0
  96. firecube/core/storage/completion.py +245 -0
  97. firecube/core/storage/driver_config.py +125 -0
  98. firecube/core/storage/results.py +30 -0
  99. firecube/core/storage/session.py +431 -0
  100. firecube/core/storage/transfer.py +142 -0
  101. firecube/core/storage/uri.py +173 -0
  102. firecube/core/tensogram/__init__.py +37 -0
  103. firecube/core/tensogram/_compat.py +36 -0
  104. firecube/core/tensogram/controlplane_codec.py +230 -0
  105. firecube/core/tensogram/converter.py +390 -0
  106. firecube/core/tensogram/metadata.py +274 -0
  107. firecube/core/tensogram/restore.py +303 -0
  108. firecube/core/tensogram/schema.py +93 -0
  109. firecube/core/uris.py +182 -0
  110. firecube/core/workspaces.py +68 -0
  111. firecube/core/zarr/__init__.py +69 -0
  112. firecube/core/zarr/_reserved_attrs.py +47 -0
  113. firecube/core/zarr/_reserved_root_attrs.py +74 -0
  114. firecube/core/zarr/io.py +88 -0
  115. firecube/core/zarr/layers.py +184 -0
  116. firecube/core/zarr/multires.py +178 -0
  117. firecube/core/zarr/region_writer.py +782 -0
  118. firecube/core/zarr/scrub.py +200 -0
  119. firecube/core/zarr/state.py +258 -0
  120. firecube/core/zarr/time_decode.py +71 -0
  121. firecube/core/zarr/validation.py +622 -0
  122. firecube/ingestor/__init__.py +27 -0
  123. firecube/ingestor/api.py +215 -0
  124. firecube/ingestor/config/__init__.py +19 -0
  125. firecube/ingestor/config/coercion.py +157 -0
  126. firecube/ingestor/config/engine.py +188 -0
  127. firecube/ingestor/contracts/__init__.py +14 -0
  128. firecube/ingestor/contracts/interfaces.py +235 -0
  129. firecube/ingestor/devtools/_templates/__init__.py +14 -0
  130. firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
  131. firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
  132. firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
  133. firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
  134. firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
  135. firecube/ingestor/devtools/scaffolding.py +229 -0
  136. firecube/ingestor/errors.py +79 -0
  137. firecube/ingestor/extensions/_binning.py +152 -0
  138. firecube/ingestor/extensions/duck.py +153 -0
  139. firecube/ingestor/extensions/grid.py +287 -0
  140. firecube/ingestor/extensions/healpix.py +330 -0
  141. firecube/ingestor/registry/__init__.py +14 -0
  142. firecube/ingestor/registry/loader.py +144 -0
  143. firecube/ingestor/registry/metadata.py +97 -0
  144. firecube/ingestor/registry/version_compat.py +95 -0
  145. firecube/ingestor/runtime/__init__.py +14 -0
  146. firecube/ingestor/runtime/aggregation.py +112 -0
  147. firecube/ingestor/runtime/base.py +836 -0
  148. firecube/ingestor/runtime/base_hooks.py +129 -0
  149. firecube/ingestor/runtime/batching.py +161 -0
  150. firecube/ingestor/runtime/configure.py +195 -0
  151. firecube/ingestor/runtime/coverage.py +177 -0
  152. firecube/ingestor/runtime/engine.py +793 -0
  153. firecube/ingestor/runtime/parallel_evidence.py +75 -0
  154. firecube/ingestor/runtime/parallel_execution_state.py +28 -0
  155. firecube/ingestor/runtime/parallel_gate.py +164 -0
  156. firecube/ingestor/runtime/parallel_run_id.py +48 -0
  157. firecube/ingestor/runtime/preflight.py +40 -0
  158. firecube/ingestor/runtime/recording.py +425 -0
  159. firecube/ingestor/runtime/resume_guard.py +556 -0
  160. firecube/ingestor/runtime/resume_types.py +65 -0
  161. firecube/ingestor/runtime/telemetry.py +171 -0
  162. firecube/ingestor/runtime/tensogram/__init__.py +14 -0
  163. firecube/ingestor/runtime/tensogram/strategy.py +164 -0
  164. firecube/ingestor/runtime/workspace.py +220 -0
  165. firecube/ingestor/runtime/zarr/__init__.py +34 -0
  166. firecube/ingestor/runtime/zarr/append.py +466 -0
  167. firecube/ingestor/runtime/zarr/append_services.py +650 -0
  168. firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
  169. firecube/ingestor/runtime/zarr/contracts.py +58 -0
  170. firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
  171. firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
  172. firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
  173. firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
  174. firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
  175. firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
  176. firecube/ingestor/runtime/zarr/write.py +212 -0
  177. firecube/ingestor/runtime/zarr/write_context.py +139 -0
  178. firecube/ingestor/templates/__init__.py +19 -0
  179. firecube/ingestor/templates/config.py +118 -0
  180. firecube/ingestor/templates/direct_zarr.py +790 -0
  181. firecube/ingestor/templates/generic.py +462 -0
  182. firecube/ingestor/templates/generic_tensogram.py +170 -0
  183. firecube/ingestor/types/__init__.py +19 -0
  184. firecube/ingestor/types/config.py +89 -0
  185. firecube/ingestor/types/context.py +409 -0
  186. firecube/ingestor/types/manifest.py +53 -0
  187. firecube/ingestor/types/planned_range.py +152 -0
  188. firecube/ingestor/types/result_metrics.py +386 -0
  189. firecube/ingestor/utils/__init__.py +14 -0
  190. firecube/ingestor/utils/duckdb_utils.py +235 -0
  191. firecube/ingestor/validation.py +55 -0
  192. firecube-0.1.4.dist-info/METADATA +242 -0
  193. firecube-0.1.4.dist-info/RECORD +197 -0
  194. firecube-0.1.4.dist-info/WHEEL +4 -0
  195. firecube-0.1.4.dist-info/entry_points.txt +2 -0
  196. firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
  197. firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,390 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Core Zarr → Tensogram (.tgm) conversion.
16
+
17
+ The produced .tgm file is openable with:
18
+ xr.open_dataset("archive.tgm", engine="tensogram")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+ import xarray as xr
28
+ import zarr
29
+
30
+ from firecube.core.controlplane.manager import ChunkManager
31
+ from firecube.core.filesystem.ops import _open_fsspec_url
32
+ from firecube.core.product.identity import ProductIdentity
33
+ from firecube.core.storage.binding import StorageBinding
34
+ from firecube.core.storage.driver_config import StorageDriverConfig
35
+ from firecube.core.storage.session import StorageSession
36
+ from firecube.core.storage.uri import StorageUri
37
+ from firecube.core.uris import is_remote_target, local_path_from_target, parse_target
38
+
39
+ from ._compat import require_tensogram
40
+ from .controlplane_codec import serialize_controlplane
41
+ from .metadata import (
42
+ _collect_safe_attrs,
43
+ extract_zarr_array_metadata,
44
+ prepare_array_for_encoding,
45
+ variable_to_base_entry,
46
+ variable_to_descriptor,
47
+ )
48
+ from .schema import make_controlplane_meta, make_data_meta
49
+
50
+ _UNENCODABLE_DTYPE_KINDS = frozenset({"U", "S", "O"})
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ def _is_encodable_dtype(dtype) -> bool:
55
+ import numpy as np
56
+
57
+ return np.dtype(dtype).kind not in _UNENCODABLE_DTYPE_KINDS
58
+
59
+
60
+ def zarr_to_tgm(
61
+ source: str,
62
+ target: str,
63
+ *,
64
+ group: str | None = None,
65
+ variables: list[str] | None = None,
66
+ start_date: str | None = None,
67
+ end_date: str | None = None,
68
+ compression: str = "zstd",
69
+ overwrite: bool = False,
70
+ storage_config: Any | None = None,
71
+ session: StorageSession | None = None,
72
+ allow_nan: bool = True,
73
+ allow_inf: bool = True,
74
+ ) -> dict[str, Any]:
75
+ """Convert a Zarr store or group into a local Tensogram archive.
76
+
77
+ ``source`` and ``target`` accept either a bare absolute path or a
78
+ canonical URI (e.g. ``file:///…``, ``s3://…``).
79
+ """
80
+ require_tensogram("zarr_to_tgm")
81
+ import tensogram # pyright: ignore[reportMissingImports]
82
+
83
+ if is_remote_target(target):
84
+ raise ValueError(
85
+ f"Remote target not yet supported for tgm output: {target}. "
86
+ "Write to a local path and upload separately."
87
+ )
88
+
89
+ session = session or _make_archive_session(source, storage_config)
90
+ source_uri = parse_target(source)
91
+
92
+ target_path = local_path_from_target(target)
93
+ if target_path.exists() and not overwrite:
94
+ raise FileExistsError(
95
+ f"Target file already exists: {target}. Use overwrite=True to replace it."
96
+ )
97
+ if target_path.exists() and overwrite:
98
+ target_path.unlink()
99
+
100
+ if group is not None:
101
+ groups_to_archive: list[str | None] = [group]
102
+ else:
103
+ zroot = session.zarr.open_group(source_uri, mode="r")
104
+ groups_to_archive = [
105
+ name for name, member in zroot.members() if isinstance(member, zarr.Group)
106
+ ]
107
+ if not groups_to_archive:
108
+ groups_to_archive = [None]
109
+
110
+ target_path.parent.mkdir(parents=True, exist_ok=True)
111
+ archived_groups: list[str] = []
112
+ archived_variables: set[str] = set()
113
+ archived_coordinates: set[str] = set()
114
+ skipped_vars: set[str] = set()
115
+ time_ranges: dict[str, dict[str, str]] = {}
116
+
117
+ with tensogram.TensogramFile.create(str(target_path)) as tensogram_file: # pyright: ignore[reportAttributeAccessIssue]
118
+ for group_name in groups_to_archive:
119
+ ds = _open_group_dataset(
120
+ session,
121
+ source_uri,
122
+ group=group_name,
123
+ )
124
+ try:
125
+ if start_date is not None or end_date is not None:
126
+ time_dim = _find_time_dim(ds)
127
+ if time_dim is None:
128
+ raise ValueError(
129
+ f"Cannot apply time filter (start_date={start_date!r}, end_date={end_date!r}): "
130
+ "dataset has no time dimension. "
131
+ f"Available dimensions: {list(ds.dims)}"
132
+ )
133
+
134
+ time_slice = slice(start_date, end_date)
135
+ ds = ds.sel({time_dim: time_slice})
136
+
137
+ if variables is not None:
138
+ missing = [name for name in variables if name not in ds.data_vars]
139
+ if missing:
140
+ raise ValueError(
141
+ f"Requested variables not found in dataset: {missing}. "
142
+ f"Available: {list(ds.data_vars)}"
143
+ )
144
+ ds = ds[variables]
145
+
146
+ coord_names = [str(name) for name in ds.coords]
147
+ zarr_meta = extract_zarr_array_metadata(
148
+ source,
149
+ group_name,
150
+ storage_config=storage_config,
151
+ session=session,
152
+ )
153
+ objects: list[tuple[dict[str, Any], np.ndarray]] = []
154
+ base: list[dict[str, Any]] = []
155
+
156
+ for var_name in ds.data_vars:
157
+ variable = ds[var_name].variable
158
+ if not _is_encodable_dtype(variable.dtype):
159
+ logger.warning(
160
+ "Skipping variable '%s': unsupported dtype %s", var_name, variable.dtype
161
+ )
162
+ skipped_vars.add(str(var_name))
163
+ continue
164
+ archived_variables.add(str(var_name))
165
+ base.append(
166
+ variable_to_base_entry(
167
+ str(var_name),
168
+ variable,
169
+ zarr_meta=zarr_meta.get(str(var_name)),
170
+ )
171
+ )
172
+ objects.append(
173
+ (
174
+ variable_to_descriptor(
175
+ str(var_name),
176
+ variable,
177
+ compression=compression,
178
+ ),
179
+ prepare_array_for_encoding(variable),
180
+ )
181
+ )
182
+
183
+ for coord_name in ds.coords:
184
+ coordinate = ds.coords[coord_name].variable
185
+ if not _is_encodable_dtype(coordinate.dtype):
186
+ logger.warning(
187
+ "Skipping variable '%s': unsupported dtype %s",
188
+ coord_name,
189
+ coordinate.dtype,
190
+ )
191
+ skipped_vars.add(str(coord_name))
192
+ continue
193
+ archived_coordinates.add(str(coord_name))
194
+ base.append(
195
+ variable_to_base_entry(
196
+ str(coord_name),
197
+ coordinate,
198
+ zarr_meta=zarr_meta.get(str(coord_name)),
199
+ )
200
+ )
201
+ objects.append(
202
+ (
203
+ variable_to_descriptor(
204
+ str(coord_name),
205
+ coordinate,
206
+ compression="none",
207
+ ),
208
+ prepare_array_for_encoding(coordinate),
209
+ )
210
+ )
211
+
212
+ global_meta = make_data_meta(
213
+ group=group_name or "",
214
+ base=base,
215
+ source_uri=source,
216
+ compression=compression,
217
+ coordinates=coord_names,
218
+ start_date=start_date,
219
+ end_date=end_date,
220
+ extra_attrs=_collect_safe_attrs(dict(ds.attrs)),
221
+ )
222
+ tensogram_file.append(
223
+ global_meta, objects, allow_nan=allow_nan, allow_inf=allow_inf
224
+ )
225
+
226
+ group_key = group_name or ""
227
+ archived_groups.append(group_key)
228
+ time_ranges[group_key] = _dataset_time_range(ds)
229
+ finally:
230
+ ds.close()
231
+
232
+ if _has_controlplane_root(source, storage_config=storage_config):
233
+ product_identity = session.product
234
+ product = product_identity.product_name
235
+ manager = ChunkManager(
236
+ binding=StorageBinding(identity=product_identity, driver=session.driver),
237
+ )
238
+ try:
239
+ cp_descriptor, cp_array = serialize_controlplane(manager, product, group=group)
240
+ cp_meta = make_controlplane_meta(product)
241
+ tensogram_file.append(
242
+ cp_meta, [(cp_descriptor, cp_array)], allow_nan=True, allow_inf=True
243
+ )
244
+ finally:
245
+ manager.close()
246
+ else:
247
+ logger.warning("No .firecube/ found at %s — archiving data only", source)
248
+
249
+ file_size = target_path.stat().st_size
250
+ single_group_time_range = time_ranges[archived_groups[0]] if len(archived_groups) == 1 else {}
251
+
252
+ return {
253
+ "source": source,
254
+ "target": str(target_path),
255
+ "variables": sorted(archived_variables),
256
+ "skipped": sorted(skipped_vars),
257
+ "coordinates": sorted(archived_coordinates),
258
+ "time_range": single_group_time_range,
259
+ "time_ranges": time_ranges,
260
+ "file_size_bytes": file_size,
261
+ "compression": compression,
262
+ "group": group,
263
+ "groups": archived_groups,
264
+ "allow_nan": allow_nan,
265
+ "allow_inf": allow_inf,
266
+ }
267
+
268
+
269
+ def _make_archive_session(source: str, storage_config: Any | None) -> StorageSession:
270
+ source_uri = parse_target(source)
271
+ driver_config = StorageDriverConfig.from_storage_config_or_default(storage_config)
272
+ return StorageSession(
273
+ StorageBinding(
274
+ identity=ProductIdentity.from_uri(source_uri, "zarr", product_name=source),
275
+ driver=driver_config,
276
+ )
277
+ )
278
+
279
+
280
+ def _has_controlplane_root(source: str, *, storage_config: Any | None = None) -> bool:
281
+ """Return True when the source product has a .firecube/ control-plane root."""
282
+ if not is_remote_target(source):
283
+ return (local_path_from_target(source) / ".firecube").exists()
284
+
285
+ try:
286
+ fs, root = _open_fsspec_url(source, storage_config=storage_config)
287
+ control_root = f"{str(root).rstrip('/')}/.firecube"
288
+ if hasattr(fs, "isdir"):
289
+ return bool(fs.isdir(control_root))
290
+ if hasattr(fs, "exists"):
291
+ return bool(fs.exists(control_root))
292
+ except Exception as exc:
293
+ logger.debug("Could not inspect control-plane root for %s: %s", source, exc)
294
+ return False
295
+
296
+
297
+ def _open_group_dataset(
298
+ session: StorageSession,
299
+ source_uri: StorageUri,
300
+ *,
301
+ group: str | None,
302
+ ) -> xr.Dataset:
303
+ """Open a zarr group as xarray, with a session-based fallback for v3 test stores."""
304
+ try:
305
+ return session.zarr.open_dataset(source_uri, group=group or None)
306
+ except KeyError as exc:
307
+ if "dimension_names" not in str(exc):
308
+ raise
309
+ logger.debug(
310
+ "Falling back to raw zarr group open for %s group=%s: %s",
311
+ source_uri.to_str(),
312
+ group,
313
+ exc,
314
+ )
315
+
316
+ zroot = session.zarr.open_group(source_uri, mode="r")
317
+ zgroup = zroot if group is None else _group_from_root(zroot, group)
318
+
319
+ data_vars: dict[str, xr.Variable] = {}
320
+ for name, member in zgroup.members():
321
+ if not isinstance(member, zarr.Array):
322
+ continue
323
+ dims = tuple(f"dim_{index}" for index in range(member.ndim))
324
+ data_vars[str(name)] = xr.Variable(
325
+ dims=dims,
326
+ data=np.asarray(member),
327
+ attrs=_collect_safe_attrs(dict(member.attrs)),
328
+ )
329
+
330
+ return xr.Dataset(data_vars=data_vars, attrs=_collect_safe_attrs(dict(zgroup.attrs)))
331
+
332
+
333
+ def _group_from_root(root: Any, group: str) -> Any:
334
+ current = root
335
+ for part in str(group).split("/"):
336
+ current = current[part]
337
+ if not isinstance(current, zarr.Group):
338
+ raise TypeError(f"Expected zarr.Group at {group!r}, got {type(current)!r}")
339
+ return current
340
+
341
+
342
+ def _find_time_dim(ds: xr.Dataset, preferred_time_dim: str | None = None) -> str | None:
343
+ """Return the dataset time dimension name when present.
344
+
345
+ When ``preferred_time_dim`` is supplied and exists in ``ds.dims``, that name
346
+ is returned immediately. This lets plugins declaring a non-default time
347
+ dimension (e.g. ``time_dim_name = 'obs_time'``) override the legacy
348
+ fallback order ``('timestamp', 'time', <first datetime64 coord>)`` even
349
+ when the dataset also exposes one of the legacy names.
350
+ """
351
+ if preferred_time_dim is not None and preferred_time_dim in ds.dims:
352
+ return preferred_time_dim
353
+
354
+ for name in ("timestamp", "time"):
355
+ if name in ds.dims:
356
+ return name
357
+
358
+ for coord_name, coord in ds.coords.items():
359
+ values = np.asarray(coord.values)
360
+ if np.issubdtype(values.dtype, np.datetime64):
361
+ return str(coord_name)
362
+
363
+ return None
364
+
365
+
366
+ def _dataset_time_range(ds: xr.Dataset, preferred_time_dim: str | None = None) -> dict[str, str]:
367
+ """Summarize the current time coverage for the dataset selection."""
368
+ time_dim = _find_time_dim(ds, preferred_time_dim=preferred_time_dim)
369
+ if time_dim is None or time_dim not in ds.coords:
370
+ return {}
371
+
372
+ values = np.asarray(ds.coords[time_dim].values)
373
+ if values.size == 0:
374
+ return {}
375
+
376
+ first = values[0]
377
+ last = values[-1]
378
+ return {
379
+ "start": _format_time_value(first),
380
+ "end": _format_time_value(last),
381
+ "n": str(values.size),
382
+ }
383
+
384
+
385
+ def _format_time_value(value: Any) -> str:
386
+ """Format datetime-like values consistently for summaries."""
387
+ array_value = np.asarray(value)
388
+ if np.issubdtype(array_value.dtype, np.datetime64):
389
+ return str(np.datetime_as_string(array_value.astype("datetime64[ns]"), unit="s"))
390
+ return str(value)
@@ -0,0 +1,274 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Metadata mapping between xarray CF conventions and Tensogram CBOR dicts.
16
+
17
+ No tensogram import at module level - this module works with plain Python dicts
18
+ that happen to be compatible with Tensogram's CBOR metadata format.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from datetime import UTC, datetime
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ import numpy as np
28
+ import xarray as xr
29
+
30
+ import firecube
31
+
32
+ if TYPE_CHECKING:
33
+ from firecube.core.storage.session import StorageSession
34
+
35
+ _NUMPY_DTYPE_TO_TENSOGRAM = {
36
+ "float16": "float16",
37
+ "float32": "float32",
38
+ "float64": "float64",
39
+ "int8": "int8",
40
+ "int16": "int16",
41
+ "int32": "int32",
42
+ "int64": "int64",
43
+ "uint8": "uint8",
44
+ "uint16": "uint16",
45
+ "uint32": "uint32",
46
+ "uint64": "uint64",
47
+ "complex64": "complex64",
48
+ "complex128": "complex128",
49
+ }
50
+
51
+ _SAFE_ATTR_TYPES = (str, int, float, bool)
52
+
53
+
54
+ def numpy_dtype_to_tensogram(dtype: np.dtype) -> str:
55
+ """Convert a numpy dtype to a Tensogram dtype string."""
56
+ return _NUMPY_DTYPE_TO_TENSOGRAM.get(np.dtype(dtype).name, np.dtype(dtype).name)
57
+
58
+
59
+ def _normalize_attr_value(value: Any) -> Any:
60
+ if isinstance(value, np.generic):
61
+ return value.item()
62
+ if isinstance(value, tuple):
63
+ return [_normalize_attr_value(item) for item in value]
64
+ if isinstance(value, list):
65
+ return [_normalize_attr_value(item) for item in value]
66
+ if isinstance(value, np.ndarray):
67
+ return [_normalize_attr_value(item) for item in value.tolist()]
68
+ if isinstance(value, float) and math.isnan(value):
69
+ return value
70
+ if isinstance(value, _SAFE_ATTR_TYPES):
71
+ return value
72
+ return None
73
+
74
+
75
+ def _collect_safe_attrs(attrs: dict[str, Any]) -> dict[str, Any]:
76
+ safe_attrs: dict[str, Any] = {}
77
+ for key, value in attrs.items():
78
+ normalized = _normalize_attr_value(value)
79
+ if normalized is not None:
80
+ safe_attrs[key] = normalized
81
+ return safe_attrs
82
+
83
+
84
+ def dataset_to_global_meta(
85
+ ds: xr.Dataset,
86
+ *,
87
+ source_uri: str,
88
+ compression: str,
89
+ base: list[dict[str, Any]] | None = None,
90
+ ) -> dict[str, Any]:
91
+ """Build a Tensogram metadata dict from an xarray Dataset.
92
+
93
+ Tensogram >=0.18 metadata frames are free-form; the wire version lives in
94
+ the file preamble, so no top-level "version" key is written.
95
+ """
96
+ meta: dict[str, Any] = {
97
+ "firecube": {
98
+ "version": getattr(firecube, "__version__", "unknown"),
99
+ "source_uri": source_uri,
100
+ "archived_at": datetime.now(UTC).isoformat(),
101
+ "compression": compression,
102
+ },
103
+ }
104
+
105
+ if base is not None:
106
+ meta["base"] = base
107
+
108
+ meta.update(_collect_safe_attrs(dict(ds.attrs)))
109
+ return meta
110
+
111
+
112
+ def _datetime64_to_float64(values: np.ndarray) -> np.ndarray:
113
+ return values.astype("datetime64[ns]").astype("float64")
114
+
115
+
116
+ def prepare_array_for_encoding(var: xr.Variable) -> np.ndarray:
117
+ """Convert an xarray variable to a contiguous array for Tensogram encoding."""
118
+ arr = np.asarray(var.values)
119
+ if np.issubdtype(arr.dtype, np.datetime64):
120
+ arr = _datetime64_to_float64(arr)
121
+ return np.ascontiguousarray(arr)
122
+
123
+
124
+ def variable_to_descriptor(
125
+ name: str,
126
+ var: xr.Variable,
127
+ *,
128
+ compression: str,
129
+ ) -> dict[str, Any]:
130
+ """Build a Tensogram DataObjectDescriptor dict from an xarray Variable."""
131
+ values = np.asarray(var.values)
132
+ if np.issubdtype(values.dtype, np.datetime64):
133
+ values = _datetime64_to_float64(values)
134
+ dtype = "float64"
135
+ else:
136
+ dtype = numpy_dtype_to_tensogram(values.dtype)
137
+
138
+ desc: dict[str, Any] = {
139
+ "type": "ntensor",
140
+ "shape": list(values.shape),
141
+ "dtype": dtype,
142
+ "compression": compression,
143
+ }
144
+
145
+ attrs = _collect_safe_attrs(dict(var.attrs))
146
+ if attrs:
147
+ desc["attrs"] = attrs
148
+
149
+ return desc
150
+
151
+
152
+ def extract_zarr_array_metadata(
153
+ store_path: str,
154
+ group: str | None,
155
+ *,
156
+ storage_config: Any | None = None,
157
+ storage_options: dict[str, Any] | None = None,
158
+ session: StorageSession | None = None,
159
+ ) -> dict[str, dict[str, Any]]:
160
+ """Extract zarr array metadata (chunks, compressor, fill_value, dtype, order).
161
+
162
+ Returns a dict mapping array name to its metadata. Only top-level arrays
163
+ within the group are returned (no recursive descent into subgroups).
164
+
165
+ Args:
166
+ store_path: Local path or s3:// URI to the zarr store.
167
+ group: Group path within the store (e.g. "F024"), or None for root.
168
+ storage_config: StorageConfig honored when the store is remote via the
169
+ session Zarr API. Only consulted when ``session`` is not provided.
170
+ storage_options: legacy fsspec storage options dict; only consulted
171
+ when neither ``session`` nor ``storage_config`` is provided.
172
+ session: Optional pre-configured StorageSession. When provided, metadata
173
+ reads use the session's driver (driver-correct path); otherwise a
174
+ fresh session is constructed from ``storage_config`` and may
175
+ silently downgrade to fsspec.
176
+
177
+ Returns:
178
+ Dict mapping variable name to metadata dict with keys:
179
+ chunks, compressor (dict or None), fill_value, dtype (str), order.
180
+ """
181
+ import zarr
182
+
183
+ from firecube.core.product.identity import ProductIdentity
184
+ from firecube.core.storage.binding import StorageBinding
185
+ from firecube.core.storage.driver_config import StorageDriverConfig
186
+ from firecube.core.storage.session import StorageSession
187
+ from firecube.core.uris import storage_uri_from_target
188
+
189
+ store_uri = storage_uri_from_target(store_path)
190
+ if session is None:
191
+ if storage_config is None:
192
+ raise ValueError(
193
+ "storage_config is required for zarr metadata extraction; pass --storage-type and --storage-driver"
194
+ )
195
+ effective_config = storage_config
196
+ session = StorageSession(
197
+ StorageBinding(
198
+ identity=ProductIdentity.from_uri(
199
+ store_uri,
200
+ format="zarr",
201
+ product_name=store_path,
202
+ ),
203
+ driver=StorageDriverConfig.from_storage_config(effective_config),
204
+ )
205
+ )
206
+ handle = session.zarr.create_store(uri=store_uri, mode="r")
207
+
208
+ open_kwargs: dict[str, Any] = {**handle.zarr_kwargs(), "mode": "r"}
209
+ if group is not None:
210
+ open_kwargs["path"] = group
211
+
212
+ zgroup = zarr.open_group(**open_kwargs)
213
+
214
+ result: dict[str, dict[str, Any]] = {}
215
+ for name, item in zgroup.members():
216
+ if not isinstance(item, zarr.Array):
217
+ continue
218
+ compressor_config: dict[str, Any] | None = None
219
+ compressors = item.compressors
220
+ if compressors:
221
+ codec = compressors[0]
222
+ to_dict = getattr(codec, "to_dict", None)
223
+ get_config = getattr(codec, "get_config", None)
224
+ if to_dict is not None:
225
+ compressor_config = to_dict()
226
+ elif get_config is not None:
227
+ compressor_config = get_config()
228
+ else:
229
+ compressor_config = {"id": str(codec)}
230
+ result[name] = {
231
+ "chunks": tuple(item.chunks),
232
+ "compressor": compressor_config,
233
+ "fill_value": item.fill_value,
234
+ "dtype": str(item.dtype),
235
+ "order": item.order,
236
+ }
237
+ return result
238
+
239
+
240
+ def variable_to_base_entry(
241
+ name: str,
242
+ var: xr.Variable,
243
+ *,
244
+ zarr_meta: dict[str, Any] | None = None,
245
+ ) -> dict[str, Any]:
246
+ """Build the meta["base"][i] entry for a variable.
247
+
248
+ If zarr_meta is provided (from extract_zarr_array_metadata), embeds
249
+ the original zarr chunk shapes, compressor config, and fill_value.
250
+ These are used by archive restore to recreate the original encoding.
251
+ """
252
+ entry: dict[str, Any] = {"name": str(name), "dim_names": list(var.dims)}
253
+ if zarr_meta is not None:
254
+ entry["zarr_chunks"] = list(zarr_meta["chunks"])
255
+ if zarr_meta.get("compressor") is not None:
256
+ entry["zarr_compressor"] = zarr_meta["compressor"]
257
+ entry["zarr_fill_value"] = zarr_meta["fill_value"]
258
+ return entry
259
+
260
+
261
+ def restore_attrs_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
262
+ """Recover xarray attrs from Tensogram-compatible metadata."""
263
+ attrs = dict(meta.get("attrs", {}))
264
+ restored: dict[str, Any] = {}
265
+ for key, value in attrs.items():
266
+ if value == "NaN":
267
+ restored[key] = float("nan")
268
+ elif value == "+Inf":
269
+ restored[key] = float("inf")
270
+ elif value == "-Inf":
271
+ restored[key] = float("-inf")
272
+ else:
273
+ restored[key] = value
274
+ return restored