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,200 @@
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 scrub orchestration (mutating) used by CLI and API.
16
+
17
+ This module *acts* on validation results: it maps debris chunk keys back to
18
+ manifest entries and deletes them via ChunkManager.
19
+
20
+ Keep this separate from `firecube.core.zarr.validation`, which is read-only.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+ from firecube.core.controlplane import ChunkInfo, ChunkManager
28
+ from firecube.core.filesystem import create_filesystem
29
+ from firecube.core.storage.binding import StorageBinding
30
+ from firecube.core.storage.session import StorageSession
31
+ from firecube.core.zarr.validation import validate_group_with_fs
32
+
33
+
34
+ @dataclass
35
+ class ScrubResult:
36
+ """Result of a scrub operation for a single group."""
37
+
38
+ product: str
39
+ group: str
40
+ extra_chunks: list[str]
41
+ deleted_chunks: int
42
+ deleted_size_bytes: int
43
+ storage_errors: list[str]
44
+ manifest_errors: list[str]
45
+
46
+
47
+ def run_scrub(
48
+ session: StorageSession,
49
+ group: str,
50
+ *,
51
+ max_chunks: int | None = None,
52
+ include_orphans: bool = False,
53
+ ) -> ScrubResult:
54
+ """Scrub debris chunks for a given array group using ChunkManager.
55
+
56
+ The ``session`` carries the product binding (URI, driver, credentials);
57
+ no string-concat URI building or raw credential dicts live here.
58
+
59
+ This helper:
60
+ 1) Validates the group to find out-of-range chunks.
61
+ 2) Maps those chunk paths back to manifest keys.
62
+ 3) Uses ChunkManager to delete only matching chunks from storage
63
+ and manifests.
64
+ """
65
+ product_uri = session.product.product_uri
66
+ product_name = session.product.product_name
67
+ fs = session.fs()
68
+
69
+ try:
70
+ report = validate_group_with_fs(
71
+ fs,
72
+ product_uri,
73
+ group,
74
+ )
75
+ except ValueError as exc:
76
+ if "is a Zarr group (container)" not in str(exc):
77
+ raise
78
+ return ScrubResult(
79
+ product=product_name,
80
+ group=group,
81
+ extra_chunks=[],
82
+ deleted_chunks=0,
83
+ deleted_size_bytes=0,
84
+ storage_errors=[],
85
+ manifest_errors=[],
86
+ )
87
+ extra_paths = list(report.extra_chunks)
88
+
89
+ if max_chunks is not None and max_chunks >= 0:
90
+ extra_paths = extra_paths[:max_chunks]
91
+
92
+ prefix = f"{product_name}/"
93
+ manifest_keys: list[str] = []
94
+ for path in extra_paths:
95
+ key = path
96
+ if prefix in path:
97
+ key = path.split(prefix, 1)[1]
98
+ manifest_keys.append(key)
99
+
100
+ if not manifest_keys:
101
+ return ScrubResult(
102
+ product=product_name,
103
+ group=group,
104
+ extra_chunks=[],
105
+ deleted_chunks=0,
106
+ deleted_size_bytes=0,
107
+ storage_errors=[],
108
+ manifest_errors=[],
109
+ )
110
+
111
+ binding = StorageBinding(identity=session.product, driver=session.driver)
112
+ manager = ChunkManager(binding=binding, filesystem=create_filesystem(binding))
113
+ try:
114
+ all_chunks: list[ChunkInfo] = []
115
+ for key in manifest_keys:
116
+ matches = manager.list_chunks(pattern=key, product=product_name)
117
+ all_chunks.extend(matches)
118
+
119
+ seen = set()
120
+ unique_chunks: list[ChunkInfo] = []
121
+ for chunk in all_chunks:
122
+ k = (chunk.product, chunk.key, chunk.manifest_path)
123
+ if k not in seen:
124
+ seen.add(k)
125
+ unique_chunks.append(chunk)
126
+
127
+ total_deleted_chunks = 0
128
+ total_deleted_size = 0
129
+ storage_errors: list[str] = []
130
+ manifest_errors: list[str] = []
131
+
132
+ if unique_chunks:
133
+ plan = manager.create_deletion_plan()
134
+ plan.chunks = unique_chunks
135
+ plan.total_size = sum(c.size for c in unique_chunks)
136
+ plan.products_affected = {product_name}
137
+ plan.manifest_files = {c.manifest_path for c in unique_chunks}
138
+
139
+ result = manager.execute_deletion(
140
+ plan,
141
+ delete_storage=True,
142
+ delete_manifest=True,
143
+ storage_config=None,
144
+ dry_run=False,
145
+ )
146
+ total_deleted_chunks += result.get("deleted_chunks", 0)
147
+ total_deleted_size += result.get("deleted_size_bytes", 0)
148
+ storage_errors.extend(result.get("storage_errors", []))
149
+ manifest_errors.extend(result.get("manifest_errors", []))
150
+
151
+ if include_orphans:
152
+ manifest_key_set = {c.key for c in unique_chunks}
153
+ orphan_keys = [k for k in manifest_keys if k not in manifest_key_set]
154
+
155
+ if orphan_keys:
156
+ from time import time as _time
157
+
158
+ now_ts = _time()
159
+ orphan_chunks: list[ChunkInfo] = [
160
+ ChunkInfo(
161
+ key=key,
162
+ product=product_name,
163
+ chunk_type="chunk",
164
+ size=0,
165
+ timestamp=now_ts,
166
+ manifest_path="",
167
+ )
168
+ for key in orphan_keys
169
+ ]
170
+
171
+ orphan_plan = manager.create_deletion_plan()
172
+ orphan_plan.chunks = orphan_chunks
173
+ orphan_plan.total_size = 0
174
+ orphan_plan.products_affected = {product_name}
175
+ orphan_plan.manifest_files = set()
176
+
177
+ orphan_result = manager.execute_deletion(
178
+ orphan_plan,
179
+ delete_storage=True,
180
+ delete_manifest=False,
181
+ storage_config=None,
182
+ dry_run=False,
183
+ )
184
+ if not orphan_result.get("storage_errors"):
185
+ total_deleted_chunks += len(orphan_keys)
186
+ total_deleted_size += orphan_result.get("deleted_size_bytes", 0)
187
+ storage_errors.extend(orphan_result.get("storage_errors", []))
188
+ manifest_errors.extend(orphan_result.get("manifest_errors", []))
189
+
190
+ return ScrubResult(
191
+ product=product_name,
192
+ group=group,
193
+ extra_chunks=manifest_keys,
194
+ deleted_chunks=total_deleted_chunks,
195
+ deleted_size_bytes=total_deleted_size,
196
+ storage_errors=storage_errors,
197
+ manifest_errors=manifest_errors,
198
+ )
199
+ finally:
200
+ manager.close()
@@ -0,0 +1,258 @@
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
+ """Shared helpers for Firecube-managed Zarr state arrays.
16
+
17
+ This module centralizes the semantics around the per-timestamp state array
18
+ (`firecube_timestamp_state`) used by span-based deletion/resume logic.
19
+
20
+ It is intentionally small and "dumb": callers decide *when* to create/update
21
+ the state array; this module only provides safe primitives.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Sequence
27
+ from typing import TYPE_CHECKING, Any, cast
28
+
29
+ from firecube.core.uris import storage_uri_from_target
30
+
31
+ if TYPE_CHECKING:
32
+ from firecube.core.config import StorageConfig
33
+ from firecube.core.filesystem.store_factory import ZarrStoreHandle
34
+
35
+ DEFAULT_STATE_MEANING: dict[str, str] = {
36
+ "0": "unknown",
37
+ "1": "present",
38
+ "2": "deleted_by_firecube",
39
+ "3": "failed_batch",
40
+ }
41
+
42
+
43
+ def _session_zarr_store(
44
+ *,
45
+ store_uri: str,
46
+ storage_config: StorageConfig,
47
+ mode: str,
48
+ ) -> ZarrStoreHandle:
49
+ from firecube.core.product.identity import ProductIdentity
50
+ from firecube.core.storage.binding import StorageBinding
51
+ from firecube.core.storage.driver_config import StorageDriverConfig
52
+ from firecube.core.storage.session import StorageSession
53
+
54
+ uri = storage_uri_from_target(store_uri)
55
+ session = StorageSession(
56
+ StorageBinding(
57
+ identity=ProductIdentity.from_uri(uri, format="zarr", product_name=store_uri),
58
+ driver=StorageDriverConfig.from_storage_config(storage_config),
59
+ )
60
+ )
61
+ return session.zarr.create_store(uri=uri, mode=mode)
62
+
63
+
64
+ def attach_timestamp_state_dataset(
65
+ dataset: Any,
66
+ *,
67
+ dim: str = "timestamp",
68
+ var_name: str = "firecube_timestamp_state",
69
+ present_value: int = 1,
70
+ meaning: dict[str, str] | None = None,
71
+ ) -> Any:
72
+ """Attach a per-timestamp state variable to an xarray Dataset (best effort).
73
+
74
+ This is used by ingestors so span-based tooling (delete spans / scrub) can
75
+ update state without relying on plugin-specific semantics.
76
+ """
77
+ meaning = meaning or DEFAULT_STATE_MEANING
78
+ try:
79
+ import numpy as np
80
+ except Exception as exc:
81
+ raise RuntimeError("numpy required for timestamp state dataset helper") from exc
82
+
83
+ if dataset is None:
84
+ return dataset
85
+
86
+ length = 0
87
+ try:
88
+ length = int(getattr(dataset, "sizes", {}).get(dim, 0))
89
+ except Exception:
90
+ length = 0
91
+ if length <= 0:
92
+ return dataset
93
+
94
+ try:
95
+ state_vals = np.full((length,), np.uint8(int(present_value)), dtype=np.uint8)
96
+ dataset[var_name] = ((dim,), state_vals)
97
+ attrs = getattr(dataset[var_name], "attrs", None)
98
+ if isinstance(attrs, dict):
99
+ attrs.update({"firecube_meaning": dict(meaning)})
100
+ else:
101
+ dataset[var_name].attrs.update({"firecube_meaning": dict(meaning)})
102
+ except Exception:
103
+ return dataset
104
+
105
+ return dataset
106
+
107
+
108
+ def _normalize_time_index_ranges(ranges: Sequence[Any]) -> list[tuple[int, int]]:
109
+ normalized: list[tuple[int, int]] = []
110
+ for pair in ranges:
111
+ if not (isinstance(pair, (list, tuple)) and len(pair) == 2):
112
+ continue
113
+ try:
114
+ start = int(pair[0])
115
+ end = int(pair[1])
116
+ except Exception:
117
+ continue
118
+ if end < start:
119
+ continue
120
+ normalized.append((start, end))
121
+ return normalized
122
+
123
+
124
+ def expand_time_index_ranges_to_chunk_boundaries(
125
+ ranges: Sequence[Any],
126
+ *,
127
+ chunk_len: int,
128
+ length: int,
129
+ ) -> list[list[int]]:
130
+ """Expand time index ranges to full chunk coverage boundaries.
131
+
132
+ This is used when deleting whole chunks for spans that are not aligned:
133
+ the state array should be updated for the full removed chunk coverage.
134
+ """
135
+ chunk_len = int(chunk_len or 0)
136
+ length = int(length or 0)
137
+ if chunk_len <= 0 or length <= 0:
138
+ return []
139
+
140
+ normalized = _normalize_time_index_ranges(ranges)
141
+ if not normalized:
142
+ return []
143
+
144
+ chunk_indices = set()
145
+ for start, end in normalized:
146
+ start_idx = start // chunk_len
147
+ end_idx = end // chunk_len
148
+ for idx in range(start_idx, end_idx + 1):
149
+ chunk_indices.add(idx)
150
+
151
+ expanded: list[list[int]] = []
152
+ for idx in sorted(i for i in chunk_indices if i >= 0):
153
+ start = idx * chunk_len
154
+ end = min(length - 1, (idx + 1) * chunk_len - 1)
155
+ if end >= start:
156
+ expanded.append([start, end])
157
+ return expanded
158
+
159
+
160
+ def ensure_timestamp_state_array(
161
+ *,
162
+ store_uri: str,
163
+ array_path: str,
164
+ length: int,
165
+ chunk_len: int,
166
+ dim: str = "timestamp",
167
+ storage_config: StorageConfig | None = None,
168
+ storage_options: dict[str, Any] | None = None,
169
+ present_value: int = 1,
170
+ fill_value: int = 0,
171
+ meaning: dict[str, str] | None = None,
172
+ ) -> None:
173
+ """Ensure a `uint8` timestamp state array exists (best effort)."""
174
+ length = int(length or 0)
175
+ if length <= 0:
176
+ return
177
+
178
+ try:
179
+ import numpy as np
180
+ import zarr
181
+ except Exception as exc:
182
+ raise RuntimeError("zarr+numpy required for timestamp state updates") from exc
183
+
184
+ meaning = meaning or DEFAULT_STATE_MEANING
185
+ chunk_len = int(chunk_len or length)
186
+ chunk_len = max(1, min(chunk_len, length))
187
+
188
+ if storage_config is not None:
189
+ handle = _session_zarr_store(store_uri=store_uri, storage_config=storage_config, mode="a")
190
+ root = zarr.open_group(**handle.zarr_kwargs(), mode="a", zarr_format=3)
191
+ else:
192
+ root = zarr.open_group(store=store_uri, mode="a", storage_options=storage_options)
193
+ parts = str(array_path).strip("/").split("/")
194
+ grp = root
195
+ for part in parts[:-1]:
196
+ grp = grp.require_group(part)
197
+
198
+ name = parts[-1]
199
+ if name in grp:
200
+ return
201
+
202
+ try:
203
+ arr = grp.create_array(
204
+ name,
205
+ shape=(length,),
206
+ dtype="u1",
207
+ chunks=(chunk_len,),
208
+ fill_value=int(fill_value),
209
+ dimension_names=(dim,),
210
+ overwrite=False,
211
+ )
212
+ arr[:] = np.uint8(int(present_value))
213
+ arr.attrs.update({"firecube_meaning": dict(meaning)})
214
+ except Exception as exc:
215
+ # Zarr v3 raises ContainsArrayError if overwrite=False.
216
+ # Fallback to existing if someone else created it in the meantime.
217
+ if "ContainsArrayError" in type(exc).__name__ or "exists" in str(exc).lower():
218
+ return
219
+ raise
220
+
221
+
222
+ def update_timestamp_state(
223
+ *,
224
+ store_uri: str,
225
+ array_path: str,
226
+ time_index_ranges: Sequence[Any],
227
+ value: int,
228
+ storage_config: StorageConfig | None = None,
229
+ storage_options: dict[str, Any] | None = None,
230
+ ) -> None:
231
+ """Best-effort update of a per-timestamp state array (uint8)."""
232
+ normalized = _normalize_time_index_ranges(time_index_ranges)
233
+ if not normalized:
234
+ return
235
+
236
+ try:
237
+ import numpy as np
238
+ import zarr
239
+ except Exception as exc:
240
+ raise RuntimeError("zarr+numpy required for timestamp state updates") from exc
241
+
242
+ if storage_config is not None:
243
+ handle = _session_zarr_store(store_uri=store_uri, storage_config=storage_config, mode="a")
244
+ root = zarr.open_group(**handle.zarr_kwargs(), mode="a", zarr_format=3)
245
+ else:
246
+ root = zarr.open_group(store=store_uri, mode="a", storage_options=storage_options)
247
+ parts = str(array_path).strip("/").split("/")
248
+ grp = root
249
+ for part in parts[:-1]:
250
+ grp = grp.require_group(part)
251
+ name = parts[-1]
252
+ if name not in grp:
253
+ return
254
+
255
+ arr = grp[name]
256
+ state_val = np.uint8(int(value))
257
+ for start, end in normalized:
258
+ cast(Any, arr)[start : end + 1] = state_val
@@ -0,0 +1,71 @@
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
+ """Self-describing time-array decode helper.
16
+
17
+ Dispatches on (dtype, attrs) — firecube's vocabulary for encoded time arrays.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ __all__ = ["decode_time_array"]
28
+
29
+
30
+ def decode_time_array(values: np.ndarray, attrs: Mapping[str, Any]) -> np.ndarray:
31
+ """Return a ``datetime64`` array decoded from *values* using *attrs*.
32
+
33
+ The decoded array preserves its native resolution rather than being forced
34
+ to a fixed granularity. Coverage bounds and dedup keys are derived from this
35
+ output, so coarsening to seconds here would silently collapse distinct
36
+ sub-second timestamps into one (corrupting dedup/coverage); coarsening is
37
+ therefore left to the storage layer, which owns the on-disk precision
38
+ contract. The resolution that ``decode_cf_datetime`` selects is range-aware,
39
+ so this also avoids forcing a finer unit that could overflow for
40
+ out-of-range epochs.
41
+ """
42
+
43
+ values = np.asarray(values)
44
+
45
+ if values.dtype.kind == "M":
46
+ return values
47
+
48
+ if values.dtype.kind in ("f", "i", "u"):
49
+ units = attrs.get("units") if attrs else None
50
+ if units is None:
51
+ raise ValueError(
52
+ f"Cannot decode numeric dtype {values.dtype!r}: no 'units' attr found. "
53
+ "Expected a units string like 'seconds since 1970-01-01'."
54
+ )
55
+ units_str = str(units)
56
+ if "since" not in units_str:
57
+ raise ValueError(
58
+ f"Cannot decode numeric dtype {values.dtype!r}: 'units' attr {units_str!r} "
59
+ "does not contain 'since'. Expected a reference-epoch string like "
60
+ "'seconds since 1970-01-01'."
61
+ )
62
+ from xarray.coding.times import decode_cf_datetime
63
+
64
+ calendar = str(attrs.get("calendar", "standard"))
65
+ decoded = decode_cf_datetime(values, units=units_str, calendar=calendar)
66
+ return np.asarray(decoded)
67
+
68
+ raise ValueError(
69
+ f"Cannot decode time array with dtype {values.dtype!r}: not a datetime64 or "
70
+ "a numeric type with 'units' containing 'since'."
71
+ )