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,69 @@
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
+ """Zarr utilities (validation, scrub, multires, state).
16
+
17
+ Public surface for CLI/API and plugins. Keep modules small and layered:
18
+ - `validation`: read-only Zarr inspection.
19
+ - `scrub`: mutating maintenance built on validation + ChunkManager.
20
+ - `multires`: multiresolution builders.
21
+ - `state`: shared Firecube state-array helpers.
22
+ """
23
+
24
+ import warnings
25
+
26
+ # Zarr v3 warns about non-Zarr files (like our manifest) in the store directory.
27
+ try:
28
+ from zarr.errors import ZarrUserWarning
29
+
30
+ warnings.filterwarnings("ignore", category=ZarrUserWarning)
31
+ except ImportError:
32
+ pass
33
+
34
+ from firecube.core.zarr.io import ZarrIO
35
+ from firecube.core.zarr.multires import MultiresConfig, ZarrMultiresBuilder
36
+ from firecube.core.zarr.region_writer import RegionZarrWriter, RegionZarrWriterProtocol
37
+ from firecube.core.zarr.scrub import ScrubResult, run_scrub
38
+ from firecube.core.zarr.state import (
39
+ DEFAULT_STATE_MEANING,
40
+ ensure_timestamp_state_array,
41
+ expand_time_index_ranges_to_chunk_boundaries,
42
+ update_timestamp_state,
43
+ )
44
+ from firecube.core.zarr.validation import (
45
+ ZarrValidationReport,
46
+ find_extra_chunks,
47
+ group_exists,
48
+ read_chunk_grid,
49
+ validate_group_with_fs,
50
+ )
51
+
52
+ __all__ = [
53
+ "DEFAULT_STATE_MEANING",
54
+ "MultiresConfig",
55
+ "RegionZarrWriter",
56
+ "RegionZarrWriterProtocol",
57
+ "ScrubResult",
58
+ "ZarrIO",
59
+ "ZarrMultiresBuilder",
60
+ "ZarrValidationReport",
61
+ "ensure_timestamp_state_array",
62
+ "expand_time_index_ranges_to_chunk_boundaries",
63
+ "find_extra_chunks",
64
+ "group_exists",
65
+ "read_chunk_grid",
66
+ "run_scrub",
67
+ "update_timestamp_state",
68
+ "validate_group_with_fs",
69
+ ]
@@ -0,0 +1,47 @@
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
+ """Registry of array attribute keys that firecube manages internally.
16
+
17
+ Plugins must not set these keys in ZarrArraySpec.attrs; doing so would
18
+ conflict with firecube's own internal bookkeeping.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Mapping
24
+ from typing import Any
25
+
26
+ __all__ = ["RESERVED_ARRAY_ATTRS", "assert_attrs_safe"]
27
+
28
+ RESERVED_ARRAY_ATTRS: frozenset[str] = frozenset(
29
+ {
30
+ "_ARRAY_DIMENSIONS",
31
+ "_FillValue",
32
+ "firecube_run_id",
33
+ "firecube_span_id",
34
+ "firecube_internal",
35
+ "firecube_static_written",
36
+ }
37
+ )
38
+
39
+
40
+ def assert_attrs_safe(attrs: Mapping[str, Any]) -> None:
41
+ """Raise ValueError if *attrs* contains any reserved key."""
42
+ for key in attrs:
43
+ if key in RESERVED_ARRAY_ATTRS:
44
+ raise ValueError(
45
+ f"Reserved attr {key!r} is managed by firecube, not the plugin. "
46
+ "Remove it from ZarrArraySpec.attrs."
47
+ )
@@ -0,0 +1,74 @@
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
+ """Guard reserved root attrs supplied by external writers.
16
+
17
+ This guard is for code paths that accept root attrs from EXTERNAL sources — user CLI options,
18
+ plugin output, or any other non-firecube author. Use this guard wherever such attrs are about
19
+ to be persisted to a Zarr root.
20
+
21
+ Firecube-internal writers MUST NOT call this guard. The slot-index service
22
+ (ChunkManager.ensure_slot_index_model._mirror_attrs) is the authoritative writer for the
23
+ reserved root attrs and writes them DIRECTLY. Drift detection on subsequent runs is handled by
24
+ the precedence matrix in _apply_slot_model_precedence, not by this guard.
25
+
26
+ In short: "assert no reserved root attrs appear in user/plugin-supplied attrs" — that is the
27
+ entire contract.
28
+
29
+ This module is DISTINCT from _reserved_attrs.py which guards array attributes. Root attributes
30
+ and array attributes are separate protection surfaces.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Mapping
36
+ from typing import Any
37
+
38
+ from firecube.core.slot_index import (
39
+ SLOT_INDEX_MODEL_ATTR,
40
+ SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR,
41
+ )
42
+
43
+ __all__ = ["RESERVED_ROOT_ATTRS", "assert_root_attrs_safe"]
44
+
45
+ RESERVED_ROOT_ATTRS: frozenset[str] = frozenset(
46
+ {
47
+ SLOT_INDEX_MODEL_ATTR,
48
+ SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR,
49
+ }
50
+ )
51
+
52
+
53
+ def assert_root_attrs_safe(attrs: Mapping[str, Any]) -> None:
54
+ """Raise ValueError if any key in attrs is a reserved firecube root attribute name.
55
+
56
+ The check is NAME-ONLY: the value content of the attr is NOT inspected.
57
+ Even a "correct-looking" value from user code is refused because this guard
58
+ exists to prevent user/plugin code paths from writing into the reserved namespace,
59
+ not to validate the value.
60
+
61
+ Args:
62
+ attrs: Mapping of root attribute names to values (typically from user/plugin
63
+ code before persisting to a Zarr root).
64
+
65
+ Raises:
66
+ ValueError: If any key in attrs overlaps with RESERVED_ROOT_ATTRS.
67
+ """
68
+ offending = RESERVED_ROOT_ATTRS.intersection(attrs)
69
+ if offending:
70
+ raise ValueError(
71
+ f"attrs contain reserved firecube root attribute(s): {sorted(offending)!r}. "
72
+ f"Reserved root attributes are managed internally by firecube "
73
+ f"(see RESERVED_ROOT_ATTRS = {sorted(RESERVED_ROOT_ATTRS)!r})."
74
+ )
@@ -0,0 +1,88 @@
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
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Any, cast
18
+
19
+ import xarray as xr
20
+ import zarr
21
+
22
+ if TYPE_CHECKING:
23
+ from firecube.core.filesystem.store_factory import ZarrStoreHandle
24
+ from firecube.core.storage.session import StorageSession
25
+ from firecube.core.storage.uri import StorageUri
26
+
27
+
28
+ class ZarrIO:
29
+ def __init__(self, session: StorageSession) -> None:
30
+ self._session = session
31
+
32
+ def create_store(self, uri: StorageUri, mode: str = "w") -> ZarrStoreHandle:
33
+ """Create a driver-aware Zarr store handle for a typed storage URI.
34
+
35
+ The returned handle can be splatted into zarr/xarray APIs via
36
+ ``handle.zarr_kwargs()`` and is constructed using the parent storage
37
+ session's configured driver.
38
+ """
39
+ from firecube.core.filesystem import create_session_zarr_store
40
+ from firecube.core.storage.session import storage_config_from_binding
41
+
42
+ return create_session_zarr_store(
43
+ uri=uri,
44
+ storage_config=storage_config_from_binding(self._session._binding),
45
+ mode=mode,
46
+ )
47
+
48
+ def open_group(self, uri: StorageUri, mode: str = "r") -> Any:
49
+ kwargs: dict[str, Any] = {**self.create_store(uri, mode).zarr_kwargs(), "mode": mode}
50
+ return zarr.open_group(**kwargs)
51
+
52
+ def open_dataset(self, uri: StorageUri, group: str = "", **xr_kwargs: Any) -> xr.Dataset:
53
+ """Open an xarray dataset from a Zarr group using the session store.
54
+
55
+ ``uri`` must be a canonical ``StorageUri``; raw URI strings are not
56
+ accepted by this elevated session API. Additional keyword arguments are
57
+ forwarded to ``xr.open_zarr`` (e.g. ``decode_times=False`` for raw
58
+ attribute inspection by structural validators).
59
+ """
60
+ handle = self.create_store(uri, mode="r")
61
+ return xr.open_zarr(**handle.zarr_kwargs(), group=group, **xr_kwargs)
62
+
63
+ def write_dataset(
64
+ self,
65
+ ds: xr.Dataset,
66
+ uri: StorageUri,
67
+ group: str,
68
+ mode: str = "w",
69
+ **kwargs: Any,
70
+ ) -> None:
71
+ """Write an xarray dataset to a Zarr group using the session store.
72
+
73
+ Store construction is centralized through ``create_store`` so local,
74
+ remote, fsspec, and obstore targets follow the same session-bound driver
75
+ configuration. ``zarr_format`` may be supplied for xarray-style callers;
76
+ only format 3 is supported by the underlying Firecube write helper.
77
+ """
78
+ handle = self.create_store(uri, mode)
79
+ zarr_format = kwargs.pop("zarr_format", 3)
80
+ if zarr_format != 3:
81
+ raise ValueError("session.zarr.write_dataset only supports zarr_format=3")
82
+ ds.to_zarr(
83
+ **handle.zarr_kwargs(),
84
+ group=group,
85
+ mode=cast(Any, mode),
86
+ zarr_format=zarr_format,
87
+ **kwargs,
88
+ )
@@ -0,0 +1,184 @@
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 logic for building multi-resolution Zarr layers."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from collections.abc import Iterable, MutableMapping
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any, Literal, cast
23
+
24
+ import xarray as xr
25
+
26
+ if TYPE_CHECKING:
27
+ from firecube.core.storage.session import StorageSession
28
+
29
+ from firecube.core.storage.uri import StorageUri
30
+
31
+ DEFAULT_MULTIRES_RESOLUTIONS: tuple[float, ...] = (1.0, 0.5)
32
+
33
+
34
+ def build_multires_layers(
35
+ zarr_store: str | Path | MutableMapping[str, bytes],
36
+ *,
37
+ session: StorageSession,
38
+ resolutions: Iterable[float] = DEFAULT_MULTIRES_RESOLUTIONS,
39
+ time_dim_name: str = "timestamp",
40
+ silent: bool = True,
41
+ logger: logging.Logger | None = None,
42
+ group: str | None = None,
43
+ strict: bool = False,
44
+ ds_input: xr.Dataset | None = None,
45
+ ) -> list[str]:
46
+ """Build coarsened multi-resolution layers inside an existing Zarr store.
47
+
48
+ Parameters
49
+ ----------
50
+ zarr_store:
51
+ Store path or mapping. When a group is provided, zarr_store is assumed
52
+ to point at the *root* of the product and `group` selects the dataset.
53
+ group:
54
+ Optional group path (e.g. "F048/FWI"). When omitted, the function
55
+ attempts to open the root dataset directly. Pass ``group`` explicitly
56
+ if the store uses a grouped layout.
57
+ strict:
58
+ When True, failures to open the base dataset are raised instead of
59
+ being converted into an empty result. This is useful for CLI/API
60
+ operations where a failure should abort the request.
61
+ ds_input:
62
+ Optional xarray Dataset to coarsen. When provided, the function coarsens
63
+ only this data and appends it to multiresolution layers (instead of
64
+ rebuilding everything from the store).
65
+ """
66
+
67
+ groups_written: list[str] = []
68
+
69
+ # Normalize resolutions
70
+ if resolutions is True:
71
+ resolutions = list(DEFAULT_MULTIRES_RESOLUTIONS)
72
+ elif isinstance(resolutions, str):
73
+ try:
74
+ # Handle "1.0,0.5" or similar
75
+ parsed = [float(r.strip()) for r in resolutions.split(",") if r.strip()]
76
+ except (ValueError, TypeError) as exc:
77
+ raise ValueError(f"Invalid resolutions: {resolutions!r}") from exc
78
+ if not parsed:
79
+ raise ValueError(f"Invalid resolutions: {resolutions!r}")
80
+ resolutions = parsed
81
+
82
+ if not resolutions or not isinstance(resolutions, Iterable):
83
+ return groups_written
84
+
85
+ def _get_base_ds(
86
+ z_store: str,
87
+ grp: str | None,
88
+ ) -> xr.Dataset:
89
+ z_uri = StorageUri.parse(z_store)
90
+ open_kwargs = {"chunks": "auto", "consolidated": False}
91
+ if grp:
92
+ return session.zarr.open_dataset(z_uri, **open_kwargs, group=grp)
93
+ try:
94
+ return session.zarr.open_dataset(z_uri, **open_kwargs)
95
+ except Exception as exc:
96
+ raise ValueError(
97
+ f"Cannot open root dataset at {z_store!r}. "
98
+ "If the store uses a grouped layout, pass the group explicitly."
99
+ ) from exc
100
+
101
+ ds_base: xr.Dataset
102
+ is_incremental = False
103
+
104
+ try:
105
+ if ds_input is not None:
106
+ ds_base = ds_input
107
+ is_incremental = True
108
+ else:
109
+ ds_base = _get_base_ds(str(zarr_store), group)
110
+ except Exception as exc:
111
+ if logger and not silent:
112
+ logger.warning("Failed to open base dataset for multires: %s", exc)
113
+ if strict:
114
+ raise
115
+ return groups_written
116
+
117
+ ds_base = ds_base.sortby(["lat", "lon"])
118
+
119
+ dlat = (
120
+ float(abs(ds_base.lat.diff("lat").mean().values))
121
+ if ds_base.sizes.get("lat", 0) > 1
122
+ else 1.0
123
+ )
124
+ dlon = (
125
+ float(abs(ds_base.lon.diff("lon").mean().values))
126
+ if ds_base.sizes.get("lon", 0) > 1
127
+ else 1.0
128
+ )
129
+
130
+ for res in resolutions:
131
+ lat_factor = max(1, round(res / dlat))
132
+ lon_factor = max(1, round(res / dlon))
133
+
134
+ # When a group is provided, write multiresolution layers under that
135
+ # group (e.g. F048/multires/1.0deg). Otherwise, default to a top-level
136
+ # multires/ path.
137
+ prefix = f"{group.rstrip('/')}/" if group else ""
138
+ group_name = f"{prefix}multires/{res}deg"
139
+
140
+ # Decide mode: if incremental and group already exists, append.
141
+ mode: Literal["w", "a"] = "w"
142
+ if is_incremental:
143
+ try:
144
+ base_s = str(zarr_store).rstrip("/")
145
+ target_uri = f"{base_s}/{group_name}"
146
+ if session.exists(StorageUri.parse(target_uri)):
147
+ mode = "a"
148
+ except Exception:
149
+ pass
150
+
151
+ if logger and not silent:
152
+ msg = "Building multi-resolution layer"
153
+ if mode == "a":
154
+ msg = "Appending to multi-resolution layer"
155
+ logger.info(
156
+ msg,
157
+ extra={
158
+ "group": group_name,
159
+ "lat_factor": lat_factor,
160
+ "lon_factor": lon_factor,
161
+ "mode": mode,
162
+ },
163
+ )
164
+
165
+ ds_coarse = cast(
166
+ Any, ds_base.coarsen(lat=lat_factor, lon=lon_factor, boundary="trim")
167
+ ).mean(skipna=True)
168
+
169
+ session.zarr.write_dataset(
170
+ ds_coarse,
171
+ StorageUri.parse(str(zarr_store)),
172
+ group=group_name,
173
+ mode=mode,
174
+ append_dim=time_dim_name if mode == "a" else None,
175
+ zarr_format=3,
176
+ safe_chunks=False,
177
+ align_chunks=True,
178
+ )
179
+
180
+ if logger and not silent:
181
+ logger.info("Multi-resolution layer written", extra={"layer": group_name})
182
+ groups_written.append(group_name)
183
+
184
+ return groups_written
@@ -0,0 +1,178 @@
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 helpers for building multi-resolution Zarr layers.
16
+
17
+ This module provides a generic, storage-aware multiresolution builder
18
+ that can operate on any existing Zarr product using the shared
19
+ StorageConfig and ChunkManager infrastructure.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from dataclasses import dataclass
26
+ from typing import Any, cast
27
+
28
+ from firecube.core.config import StorageConfig
29
+ from firecube.core.controlplane import ChunkManager
30
+ from firecube.core.product.identity import ProductIdentity
31
+ from firecube.core.product.target import ProductTarget
32
+ from firecube.core.storage.binding import StorageBinding
33
+ from firecube.core.storage.driver_config import StorageDriverConfig
34
+ from firecube.core.storage.session import StorageSession
35
+ from firecube.core.storage.uri import StorageUri
36
+ from firecube.core.zarr.layers import build_multires_layers
37
+
38
+ log = logging.getLogger("firecube.core.zarr.multires")
39
+
40
+
41
+ @dataclass
42
+ class MultiresConfig:
43
+ """Configuration for building multiresolution Zarr layers."""
44
+
45
+ product: str
46
+ group: str
47
+ resolutions: list[float]
48
+ time_dim_name: str = "timestamp"
49
+
50
+
51
+ class ZarrMultiresBuilder:
52
+ """Generic multiresolution builder for Zarr products.
53
+
54
+ This operates on an existing Zarr product by:
55
+ - resolving the store URI from StorageConfig
56
+ - opening the target group
57
+ - building multiresolution views using shared zarr_utils helpers
58
+
59
+ It is intentionally generic; product-specific helpers (e.g. ExampleProductMultires)
60
+ should build MultiresConfig instances and call .build().
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ storage_config: StorageConfig,
66
+ *,
67
+ product_name: str,
68
+ product_uri: StorageUri | None = None,
69
+ chunk_manager: ChunkManager | None = None,
70
+ session: StorageSession | None = None,
71
+ ) -> None:
72
+ if not product_name:
73
+ raise ValueError("ZarrMultiresBuilder requires a non-empty product_name.")
74
+ self.storage_config = storage_config
75
+ self.product_name = product_name
76
+ self.session = session
77
+ resolved_product_uri = product_uri or (
78
+ session.product.product_uri if session is not None else None
79
+ )
80
+ if resolved_product_uri is None:
81
+ raise ValueError("ZarrMultiresBuilder requires product_uri or session.")
82
+ self.product_uri: StorageUri = resolved_product_uri
83
+ driver_config = StorageDriverConfig.from_storage_config(storage_config)
84
+ # This binding intentionally uses the sentinel identity
85
+ # "__firecube_controlplane__" so multires can address the cube root as a
86
+ # product-agnostic control-plane scope instead of a real product name.
87
+ # The sentinel bypasses the product-name short-circuit in
88
+ # _ControlRootResolver. ChunkManager is currently instantiated but not
89
+ # used in build(); it is reserved here for future control-plane hooks.
90
+ self.chunk_manager = chunk_manager or ChunkManager(
91
+ binding=StorageBinding(
92
+ identity=ProductIdentity.from_uri(
93
+ self.product_uri.parent().join("__firecube_controlplane__"),
94
+ "zarr",
95
+ product_name="__firecube_controlplane__",
96
+ ),
97
+ driver=driver_config,
98
+ )
99
+ )
100
+
101
+ def _store_uri_for(self, product: str) -> str:
102
+ """Build the underlying store URI for a given product."""
103
+ product_uri = (
104
+ self.product_uri
105
+ if product.strip("/") == self.product_name
106
+ else self.product_uri.parent().join(product)
107
+ )
108
+ return product_uri.to_str()
109
+
110
+ def _session_for(self, cfg: MultiresConfig) -> StorageSession:
111
+ if self.session is not None:
112
+ return self.session
113
+
114
+ driver_config = StorageDriverConfig.from_storage_config(self.storage_config)
115
+ resolved_product = ProductTarget.resolve(
116
+ cfg.product,
117
+ driver_config,
118
+ product_name=cfg.product,
119
+ plugin_default_format="zarr",
120
+ default_base_uri=self.product_uri.parent(),
121
+ )
122
+ return StorageSession(
123
+ StorageBinding(
124
+ identity=ProductIdentity(
125
+ product_name=resolved_product.product_name,
126
+ product_uri=resolved_product.product_uri,
127
+ control_root_uri=resolved_product.control_root_uri,
128
+ format=resolved_product.format,
129
+ ),
130
+ driver=driver_config,
131
+ )
132
+ )
133
+
134
+ def build(self, cfg: MultiresConfig) -> dict:
135
+ """Build multiresolution layers for the given configuration."""
136
+ if not cfg.resolutions:
137
+ raise ValueError("At least one resolution must be provided for multires.")
138
+
139
+ store_uri = self._store_uri_for(cfg.product)
140
+ log.info(
141
+ "Building Zarr multiresolution layers",
142
+ extra={
143
+ "product": cfg.product,
144
+ "group": cfg.group,
145
+ "store_uri": store_uri,
146
+ "resolutions": cfg.resolutions,
147
+ },
148
+ )
149
+
150
+ # Delegate to shared helper
151
+ build_fn = cast(Any, build_multires_layers)
152
+ layers = build_fn(
153
+ store_uri,
154
+ resolutions=cfg.resolutions,
155
+ silent=False,
156
+ logger=log,
157
+ group=cfg.group,
158
+ strict=True,
159
+ session=self._session_for(cfg),
160
+ time_dim_name=cfg.time_dim_name,
161
+ )
162
+
163
+ log.info(
164
+ "Zarr multiresolution build complete",
165
+ extra={
166
+ "product": cfg.product,
167
+ "group": cfg.group,
168
+ "store_uri": store_uri,
169
+ "layers": layers,
170
+ },
171
+ )
172
+
173
+ return {
174
+ "product": cfg.product,
175
+ "group": cfg.group,
176
+ "store_uri": store_uri,
177
+ "layers": layers,
178
+ }