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.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- 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
|