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,426 @@
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
+ """IndexedRegionStrategy — direct-Zarr region writes via ``RegionZarrWriter``.
16
+
17
+ Implements the write-execution loop for ``DirectZarrIngestor``:
18
+ open store → ensure groups/arrays → execute ``WriteIntent`` list →
19
+ build coverage entries. Uses ``RegionZarrWriter`` for all store I/O
20
+ and ``CoverageTracker`` for coverage bookkeeping.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import dataclasses
27
+ import logging
28
+ import time
29
+ from collections.abc import Callable, Sequence
30
+ from dataclasses import replace
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ import numpy as np
34
+
35
+ from firecube.core.errors import SchemaDriftError
36
+ from firecube.core.uris import storage_uri_from_target
37
+ from firecube.core.zarr.region_writer import (
38
+ RegionZarrWriter,
39
+ _arrays_equal_missing_aware,
40
+ )
41
+ from firecube.ingestor.runtime.coverage import CoverageTracker
42
+ from firecube.ingestor.runtime.parallel_evidence import log_filter_evidence
43
+
44
+ if TYPE_CHECKING:
45
+ from firecube.core.config import StorageConfig
46
+ from firecube.core.storage.session import StorageSession
47
+
48
+
49
+ def _session_for_store(store_uri: str, storage_config: StorageConfig) -> StorageSession:
50
+ from firecube.core.product.identity import ProductIdentity
51
+ from firecube.core.storage.binding import StorageBinding
52
+ from firecube.core.storage.driver_config import StorageDriverConfig
53
+ from firecube.core.storage.session import StorageSession
54
+
55
+ uri = storage_uri_from_target(store_uri)
56
+ return StorageSession(
57
+ StorageBinding(
58
+ identity=ProductIdentity.from_uri(uri, format="zarr", product_name=store_uri),
59
+ driver=StorageDriverConfig.from_storage_config(storage_config),
60
+ )
61
+ )
62
+
63
+
64
+ log = logging.getLogger("firecube.runtime.zarr.strategies.indexed_region")
65
+
66
+ # Reserved array attr stamped once a static (write-once) array's data has been
67
+ # committed. Its presence — not the array contents — is the authoritative
68
+ # "already written in a prior run" signal on resume. Registered in
69
+ # ``firecube.core.zarr._reserved_attrs.RESERVED_ARRAY_ATTRS``.
70
+ _STATIC_WRITTEN_ATTR = "firecube_static_written"
71
+
72
+
73
+ def _schema_dimension_names(arr_spec: Any, time_coord_name: str) -> tuple[str, ...] | None:
74
+ explicit = getattr(arr_spec, "dimension_names", None)
75
+ if explicit is not None:
76
+ return explicit
77
+ shape = tuple(getattr(arr_spec, "shape", ()))
78
+ if getattr(arr_spec, "name", None) == time_coord_name and len(shape) == 1:
79
+ return (time_coord_name,)
80
+ return None
81
+
82
+
83
+ class IndexedRegionStrategy:
84
+ """Write strategy that dispatches ``WriteIntent`` objects to ``RegionZarrWriter``.
85
+
86
+ Implements the ``RegionWriteStrategy`` Protocol.
87
+
88
+ Constructed with a store URI and schema; ``write_groups()`` receives
89
+ the per-batch intents and returns coverage metrics.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ *,
95
+ store_uri: str,
96
+ schema: Sequence[Any] | None = None,
97
+ coord_names_by_group: dict[str, frozenset[str]] | None = None,
98
+ time_coord_name: str = "timestamp",
99
+ storage_config: StorageConfig | None = None,
100
+ session: StorageSession | None = None,
101
+ ) -> None:
102
+ self._store_uri = store_uri
103
+ self._schema = schema or []
104
+ self._coord_names_by_group = coord_names_by_group or {}
105
+ self._time_coord_name = time_coord_name
106
+ self._storage_config = storage_config
107
+ self._session = session
108
+
109
+ def write_groups(
110
+ self,
111
+ *,
112
+ group_to_intents: dict[str, list[Any]],
113
+ schema: Sequence[Any] | None = None,
114
+ claim_for_group: Callable[[str], Any] | None = None,
115
+ claim_for_slot: Callable[[str, int], Any] | None = None,
116
+ slot_range: tuple[int, int] | None = None,
117
+ slot_group: str | None = None,
118
+ ) -> dict[str, Any]:
119
+ """Execute write intents grouped by Zarr group and return metrics.
120
+
121
+ Parameters
122
+ ----------
123
+ group_to_intents:
124
+ Mapping of group name to list of ``WriteIntent`` objects.
125
+ schema:
126
+ Group specifications for array creation. Falls back to the
127
+ constructor-provided schema if ``None``.
128
+ claim_for_group:
129
+ Optional callback returning a context manager for group/schema
130
+ coordination. When ``claim_for_slot`` is provided, this protects
131
+ schema setup only.
132
+ claim_for_slot:
133
+ Optional callback returning a context manager for per-``ts_index``
134
+ intent dispatch. Falls back to ``claim_for_group`` and then a null
135
+ context when omitted.
136
+ slot_range:
137
+ Optional half-open ``[start, end)`` slot range. When provided, all
138
+ intents are validated before any schema setup or writes occur.
139
+ slot_group:
140
+ Optional group name that this pod owns. When set together with
141
+ ``slot_range``, the post-intent assertion only validates intents
142
+ for ``slot_group``; intents for other groups are skipped with a
143
+ warning (not silently dropped) and no writes happen for them.
144
+
145
+ Returns
146
+ -------
147
+ dict with ``"coverage"`` list and ``"duration_s"`` float.
148
+ """
149
+ effective_schema = schema if schema is not None else self._schema
150
+ t0 = time.monotonic()
151
+ tracker = CoverageTracker(time_dim_name=self._time_coord_name)
152
+
153
+ schema_by_group: dict[str, Any] = {}
154
+ for spec in effective_schema:
155
+ schema_by_group[spec.group] = spec
156
+
157
+ if slot_range is not None:
158
+ slot_start, slot_end = slot_range
159
+ all_intents = [
160
+ intent
161
+ for intents in group_to_intents.values()
162
+ for intent in intents
163
+ if hasattr(intent, "ts_index")
164
+ ]
165
+ for group_name, intents in group_to_intents.items():
166
+ # Phase 3.1 T7: When slot_group is set, only validate that group's
167
+ # intents; skip + warn for other groups (this pod does not own them).
168
+ if slot_group is not None and group_name != slot_group:
169
+ if intents:
170
+ log.warning(
171
+ "slot_group=%r: skipping %d intent(s) for group %r "
172
+ "(this pod does not own that group)",
173
+ slot_group,
174
+ len(intents),
175
+ group_name,
176
+ )
177
+ continue
178
+ for intent in intents:
179
+ if getattr(intent, "kind", None) == "static":
180
+ # Static arrays carry no time semantics; ts_index is
181
+ # semantically ignored, so slot-range bounds do not apply.
182
+ continue
183
+ ts = getattr(intent, "ts_index", None)
184
+ if ts is not None and not (slot_start <= ts < slot_end):
185
+ from firecube.ingestor.errors import WriteIntentRangeError
186
+
187
+ raise WriteIntentRangeError(
188
+ f"Intent ts_index={ts} for group={group_name!r} is outside "
189
+ f"assigned slot_range=[{slot_start}, {slot_end}). "
190
+ "Plugin filter is advisory; this is a correctness violation."
191
+ )
192
+ log_filter_evidence(
193
+ log,
194
+ stage="post_intent_assertion",
195
+ planned_range=slot_range,
196
+ original_count=len(all_intents),
197
+ filtered_count=len(all_intents),
198
+ dropped_count=0,
199
+ )
200
+
201
+ for group_name, intents in group_to_intents.items():
202
+ # Phase 3.1 T7: When slot_group is set, skip writes for other groups
203
+ # (warning already logged above during the assertion pass).
204
+ if slot_group is not None and slot_range is not None and group_name != slot_group:
205
+ continue
206
+ coord_names = self._coord_names_by_group.get(
207
+ group_name, frozenset({"y", "x", "channel"})
208
+ )
209
+ group_spec = schema_by_group.get(group_name)
210
+ if group_spec is not None:
211
+ static_names = frozenset(
212
+ arr.name for arr in group_spec.arrays if not getattr(arr, "time_indexed", True)
213
+ )
214
+ coord_names = coord_names | static_names
215
+ zarr_store = None
216
+ if self._storage_config:
217
+ session = self._session or _session_for_store(self._store_uri, self._storage_config)
218
+ zarr_store = session.zarr.create_store(
219
+ uri=storage_uri_from_target(self._store_uri),
220
+ mode="a",
221
+ ).store
222
+ writer = RegionZarrWriter(
223
+ self._store_uri,
224
+ store=zarr_store,
225
+ coord_names=coord_names,
226
+ time_coord_name=self._time_coord_name,
227
+ )
228
+
229
+ intents_with_ts = [
230
+ intent for intent in intents if getattr(intent, "ts_index", None) is not None
231
+ ]
232
+ if intents_with_ts and group_spec is not None:
233
+ max_ts = max(intent.ts_index for intent in intents_with_ts) + 1
234
+ augmented_arrays = type(group_spec.arrays)(
235
+ replace(arr, expected_time_count=max_ts)
236
+ if hasattr(arr, "expected_time_count")
237
+ and arr.expected_time_count is None
238
+ and getattr(arr, "time_indexed", True)
239
+ else arr
240
+ for arr in group_spec.arrays
241
+ )
242
+ group_spec = replace(group_spec, arrays=augmented_arrays)
243
+ # In slot-range (parallel) mode the global schema is created/verified
244
+ # exactly once per pod at startup (_verify_schema_at_pod_startup), so
245
+ # repeating it here under an exclusive per-batch claim is redundant and
246
+ # would make concurrent pods on the same group race on idempotent setup.
247
+ # Skip the claim and the ensure in that mode only; single-pod runs keep
248
+ # the §24 claim-then-ensure ordering byte-for-byte unchanged.
249
+ parallel_mode = slot_range is not None
250
+ schema_ctx = (
251
+ contextlib.nullcontext()
252
+ if parallel_mode or claim_for_group is None
253
+ else claim_for_group(group_name)
254
+ )
255
+ with schema_ctx:
256
+ if group_spec is not None and not parallel_mode:
257
+ for arr_spec in group_spec.arrays:
258
+ if arr_spec.time_indexed:
259
+ expected_time_count = getattr(arr_spec, "expected_time_count", None)
260
+ effective_shape = (
261
+ (expected_time_count, *arr_spec.shape[1:])
262
+ if expected_time_count is not None
263
+ else arr_spec.shape
264
+ )
265
+ else:
266
+ effective_shape = arr_spec.shape
267
+ allow_grow = bool(intents_with_ts)
268
+ writer.ensure_group(
269
+ f"{group_name}/{arr_spec.name}",
270
+ shape=effective_shape,
271
+ dtype=arr_spec.dtype,
272
+ fill_value=arr_spec.fill_value,
273
+ chunks=arr_spec.chunks,
274
+ allow_grow=allow_grow,
275
+ shards=arr_spec.shards,
276
+ attrs=arr_spec.attrs,
277
+ dimension_names=_schema_dimension_names(
278
+ arr_spec,
279
+ self._time_coord_name,
280
+ ),
281
+ )
282
+ writer.set_group_attrs(group_name, getattr(group_spec, "attrs", None))
283
+
284
+ static_intents = [
285
+ intent for intent in intents if getattr(intent, "kind", None) == "static"
286
+ ]
287
+ timed_intents = [
288
+ intent for intent in intents if getattr(intent, "kind", None) != "static"
289
+ ]
290
+
291
+ for intent in static_intents:
292
+ self._dispatch_static_intent(writer, intent)
293
+
294
+ intents_by_slot: dict[int, list[Any]] = {}
295
+ for intent in timed_intents:
296
+ intents_by_slot.setdefault(intent.ts_index, []).append(intent)
297
+
298
+ for ts_index, slot_intents in intents_by_slot.items():
299
+ if claim_for_slot is not None:
300
+ slot_ctx = claim_for_slot(group_name, ts_index)
301
+ elif claim_for_group is not None:
302
+ slot_ctx = claim_for_group(group_name)
303
+ else:
304
+ slot_ctx = contextlib.nullcontext()
305
+ with slot_ctx:
306
+ for intent in slot_intents:
307
+ self._dispatch_intent(writer, intent)
308
+ if intent.kind == "timestamp" and intent.timestamp_val is not None:
309
+ tracker.record_write(
310
+ group=group_name,
311
+ arrays=[self._time_coord_name],
312
+ ts_index=intent.ts_index,
313
+ time_val=intent.timestamp_val,
314
+ aligned=True,
315
+ )
316
+ elif intent.kind == "region":
317
+ tracker.record_write(
318
+ group=group_name,
319
+ arrays=[intent.array],
320
+ ts_index=intent.ts_index,
321
+ time_val=None,
322
+ aligned=True,
323
+ )
324
+ elif intent.kind == "1d":
325
+ # Only advance coverage time bounds when this 1-D write
326
+ # targets the declared time-coord array AND carries a real
327
+ # timestamp value. Non-time-coord 1-D writes still register
328
+ # their index range (for span coverage) but pass
329
+ # ``time_val=None`` so they cannot poison ``time_min``/
330
+ # ``time_max`` with arbitrary data.
331
+ is_time_coord = intent.array == self._time_coord_name
332
+ time_val = (
333
+ intent.timestamp_val
334
+ if is_time_coord and intent.timestamp_val is not None
335
+ else None
336
+ )
337
+ tracker.record_write(
338
+ group=group_name,
339
+ arrays=[intent.array],
340
+ ts_index=intent.ts_index,
341
+ time_val=time_val,
342
+ aligned=True,
343
+ )
344
+
345
+ coverage = [dataclasses.asdict(c) for c in tracker.build_coverage()]
346
+ return {
347
+ "coverage": coverage,
348
+ "duration_s": time.monotonic() - t0,
349
+ }
350
+
351
+ @classmethod
352
+ def _dispatch_static_intent(cls, writer: RegionZarrWriter, intent: Any) -> None:
353
+ """Dispatch a ``kind="static"`` intent with idempotent resume semantics.
354
+
355
+ Static arrays are write-once: a re-run on the same store must either
356
+ replay the identical bytes (no-op) or fail loudly.
357
+
358
+ ``ensure_group`` pre-creates the static array at its declared shape
359
+ (filled with ``fill_value``) during schema setup, so the array always
360
+ exists by the time this runs. "Was this committed in a prior run?"
361
+ therefore cannot be answered from array contents — an all-fill array is
362
+ indistinguishable from legitimate all-fill data (e.g. an all-NaN/NaT
363
+ placeholder mask), and inferring freshness from contents would silently
364
+ overwrite such data on resume. Instead firecube stamps the reserved
365
+ ``firecube_static_written`` marker attr once the write commits:
366
+
367
+ - marker absent → never committed (a fresh shell, or a crash before
368
+ commit): write the data, then stamp the marker. The full array is not
369
+ read, so first writes stay O(1) in store reads.
370
+ - marker present → committed previously: the incoming data must replay
371
+ the identical bytes (NaN/NaT-aware via ``equal_nan=True``), else
372
+ :class:`SchemaDriftError`. Re-writing is skipped on an exact match.
373
+ """
374
+ arr_path = f"{intent.group}/{intent.array}"
375
+ root = writer._open_root()
376
+ try:
377
+ arr = root[arr_path]
378
+ except KeyError:
379
+ arr = None
380
+ if arr is not None and bool(arr.attrs.get(_STATIC_WRITTEN_ATTR, False)):
381
+ existing = np.asarray(arr[:])
382
+ if not _arrays_equal_missing_aware(existing, intent.data):
383
+ raise SchemaDriftError(
384
+ f"Static array {arr_path!r} diverged from existing data on "
385
+ "resume. Re-ingest from scratch; static arrays are write-once."
386
+ )
387
+ return
388
+ cls._dispatch_intent(writer, intent)
389
+ root[arr_path].attrs[_STATIC_WRITTEN_ATTR] = True
390
+
391
+ @staticmethod
392
+ def _dispatch_intent(writer: RegionZarrWriter, intent: Any) -> None:
393
+ """Route a single ``WriteIntent`` to the appropriate writer method."""
394
+ if intent.kind == "region":
395
+ writer.write_region(
396
+ group=intent.group,
397
+ array_name=intent.array,
398
+ ts_index=intent.ts_index,
399
+ y_slice=intent.y_slice,
400
+ data=intent.data,
401
+ channel_index=intent.channel_index,
402
+ )
403
+ elif intent.kind == "1d":
404
+ writer.write_1d(
405
+ group=intent.group,
406
+ array_name=intent.array,
407
+ ts_index=intent.ts_index,
408
+ data=intent.data,
409
+ )
410
+ elif intent.kind == "timestamp":
411
+ writer.write_timestamp(
412
+ group=intent.group,
413
+ ts_index=intent.ts_index,
414
+ timestamp_val=intent.timestamp_val,
415
+ )
416
+ elif intent.kind == "static":
417
+ writer.write_static(
418
+ group=intent.group,
419
+ array_name=intent.array,
420
+ data=intent.data,
421
+ )
422
+ else:
423
+ raise ValueError(f"Unknown WriteIntent kind: {intent.kind!r}")
424
+
425
+
426
+ __all__ = ["IndexedRegionStrategy"]
@@ -0,0 +1,212 @@
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 write helpers for Firecube ingestors.
16
+
17
+ Centralizes chunking, encoding/compression, and metadata consolidation so that
18
+ plugins/templates don't re-implement Zarr plumbing.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import warnings
25
+ from typing import TYPE_CHECKING, Any, Literal, cast
26
+
27
+ import xarray as xr
28
+ import zarr
29
+ from zarr.codecs import BloscCodec
30
+
31
+ if TYPE_CHECKING:
32
+ from firecube.core.filesystem.store_factory import ZarrStoreHandle
33
+
34
+
35
+ def _consolidate_metadata_best_effort(
36
+ opened_store: object, *, logger: logging.Logger | None
37
+ ) -> None:
38
+ with warnings.catch_warnings():
39
+ warnings.filterwarnings(
40
+ "ignore",
41
+ message="Consolidated metadata is currently not part in the Zarr format 3 specification",
42
+ )
43
+ try:
44
+ zarr.consolidate_metadata(opened_store)
45
+ except Exception as exc: # pragma: no cover - best effort
46
+ if logger:
47
+ logger.warning("Failed to consolidate Zarr metadata: %s", exc)
48
+
49
+
50
+ def auto_inner_chunk_size(size: int) -> int:
51
+ """Return the largest divisor of *size* that is ≤ size//4 (minimum 1)."""
52
+ inner = max(1, size // 4)
53
+ while inner > 1 and size % inner != 0:
54
+ inner -= 1
55
+ return inner
56
+
57
+
58
+ def _build_zarr_encoding(
59
+ ds: xr.Dataset,
60
+ *,
61
+ compression: bool | str,
62
+ shard_shape: dict[str, int] | None = None,
63
+ chunk_shape: dict[str, int] | None = None,
64
+ ) -> dict[str, dict[str, object]]:
65
+ if compression is False and shard_shape is None:
66
+ return {str(var): {} for var in ds.data_vars}
67
+
68
+ compressors: list[Any] | None = None
69
+ if compression is not False:
70
+ cname = compression if isinstance(compression, str) else "zstd"
71
+ compressors = [BloscCodec(cname=cast(Any, cname), clevel=5)]
72
+
73
+ encoding: dict[str, dict[str, object]] = {}
74
+ for var_name, data_array in ds.data_vars.items():
75
+ var_name_str = str(var_name)
76
+ var_encoding: dict[str, object] = {}
77
+ if compressors is not None:
78
+ var_encoding["compressors"] = compressors
79
+ if shard_shape is not None:
80
+ var_dims = tuple(str(dim) for dim in data_array.dims)
81
+ var_encoding["shards"] = tuple(shard_shape.get(dim, ds.sizes[dim]) for dim in var_dims)
82
+ var_encoding["chunks"] = tuple((chunk_shape or {}).get(dim, 1) for dim in var_dims)
83
+ encoding[var_name_str] = var_encoding
84
+ return encoding
85
+
86
+
87
+ def write_dataset_to_zarr(
88
+ ds: xr.Dataset,
89
+ *,
90
+ zarr_store: ZarrStoreHandle,
91
+ group: str,
92
+ mode: Literal["w", "a"] = "w",
93
+ append_dim: str | None = None,
94
+ chunk_shape: dict[str, int] | None = None,
95
+ shard_shape: dict[str, int] | None = None,
96
+ sharding: bool = False,
97
+ compression: bool | str = False,
98
+ consolidate: bool = False,
99
+ zarr_format: int = 3,
100
+ logger: logging.Logger | None = None,
101
+ ) -> None:
102
+ """Write an xarray Dataset into a Zarr V3 group with optional append semantics."""
103
+
104
+ if zarr_format != 3:
105
+ raise ValueError("write_dataset_to_zarr only supports zarr_format=3")
106
+ zarr_kwargs = zarr_store.zarr_kwargs()
107
+ effective_store = zarr_kwargs["store"]
108
+
109
+ effective_shard_shape: dict[str, int] | None = shard_shape
110
+ effective_chunk_shape: dict[str, int] | None = chunk_shape
111
+
112
+ if sharding and shard_shape is None:
113
+ effective_shard_shape = {str(dim): ds.sizes[dim] for dim in ds.dims}
114
+ if chunk_shape is None:
115
+ effective_chunk_shape = {
116
+ dim: auto_inner_chunk_size(size) for dim, size in effective_shard_shape.items()
117
+ }
118
+
119
+ # If shard_shape is explicit but chunk_shape was not given, auto-derive inner chunks
120
+ # from the shard dimensions using the same size//4 heuristic.
121
+ if effective_shard_shape is not None and effective_chunk_shape is None:
122
+ effective_chunk_shape = {
123
+ dim: auto_inner_chunk_size(size) for dim, size in effective_shard_shape.items()
124
+ }
125
+
126
+ if effective_shard_shape:
127
+ try:
128
+ current_chunks = getattr(ds, "chunks", None)
129
+ needs_rechunk = (
130
+ current_chunks is None
131
+ or any(
132
+ current_chunks.get(dim, ())
133
+ and any(c != target for c in current_chunks[dim][:-1])
134
+ for dim, target in effective_shard_shape.items()
135
+ if dim in current_chunks
136
+ )
137
+ or any(dim not in current_chunks for dim in effective_shard_shape)
138
+ )
139
+ if needs_rechunk:
140
+ ds = ds.chunk(effective_shard_shape)
141
+ except ImportError as exc: # pragma: no cover - requires dask
142
+ raise RuntimeError(
143
+ "Dask is required for chunked Zarr export. Install dask[array] or disable chunking."
144
+ ) from exc
145
+ elif chunk_shape:
146
+ try:
147
+ current_chunks = getattr(ds, "chunks", None)
148
+ needs_rechunk = (
149
+ current_chunks is None
150
+ or any(
151
+ current_chunks.get(dim, ())
152
+ and any(c != target for c in current_chunks[dim][:-1])
153
+ for dim, target in chunk_shape.items()
154
+ if dim in current_chunks
155
+ )
156
+ or any(dim not in current_chunks for dim in chunk_shape)
157
+ )
158
+ if needs_rechunk:
159
+ ds = ds.chunk(chunk_shape)
160
+ except ImportError as exc: # pragma: no cover - requires dask
161
+ raise RuntimeError(
162
+ "Dask is required for chunked Zarr export. Install dask[array] or disable chunking."
163
+ ) from exc
164
+
165
+ encoding: dict[str, dict[str, object]] | None = None
166
+ if mode == "a":
167
+ if not append_dim:
168
+ raise ValueError("append_dim is required when mode='a'")
169
+ append_dim_for_write = append_dim
170
+ else:
171
+ append_dim_for_write = None
172
+ if effective_shard_shape is not None or chunk_shape:
173
+ # Encoding is only supplied for initial writes; appends should reuse
174
+ # existing store metadata for safety.
175
+ encoding = _build_zarr_encoding(
176
+ ds,
177
+ compression=compression,
178
+ shard_shape=effective_shard_shape,
179
+ chunk_shape=effective_chunk_shape,
180
+ )
181
+ elif compression:
182
+ encoding = _build_zarr_encoding(
183
+ ds,
184
+ compression=compression,
185
+ shard_shape=effective_shard_shape,
186
+ chunk_shape=effective_chunk_shape,
187
+ )
188
+
189
+ to_zarr_common: dict[str, Any] = {
190
+ **zarr_kwargs,
191
+ "group": group,
192
+ "mode": mode,
193
+ "zarr_format": zarr_format,
194
+ "consolidated": False,
195
+ "safe_chunks": False,
196
+ "align_chunks": True,
197
+ }
198
+
199
+ with warnings.catch_warnings():
200
+ warnings.filterwarnings(
201
+ "ignore",
202
+ message="Consolidated metadata is currently not part in the Zarr format 3 specification",
203
+ )
204
+ if mode == "a":
205
+ ds.to_zarr(**to_zarr_common, append_dim=append_dim_for_write)
206
+ elif encoding is not None:
207
+ ds.to_zarr(**to_zarr_common, encoding=encoding)
208
+ else:
209
+ ds.to_zarr(**to_zarr_common)
210
+
211
+ if consolidate:
212
+ _consolidate_metadata_best_effort(effective_store, logger=logger)