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,782 @@
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
+ """Reusable direct-Zarr region writer for streaming ingestors.
16
+
17
+ Provides ``RegionZarrWriter`` — a lazy-open writer that creates groups/arrays
18
+ on demand and writes spatial regions at timestamp slots. Timestamped arrays must
19
+ be preallocated before writes, usually via ``ZarrArraySpec.expected_time_count``
20
+ or the auto-computation in ``IndexedRegionStrategy``.
21
+
22
+ Extracted from the MTG FCI plugin's ``ZarrStoreWriter``; all sensor-specific
23
+ logic has been removed. Use ``coord_names`` to specify which array names are
24
+ coordinate axes and should be skipped by :meth:`ensure_timestamp_slot`.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from collections.abc import Mapping, Sequence
31
+ from pathlib import Path
32
+ from typing import Any, Protocol, runtime_checkable
33
+
34
+ import numpy as np
35
+ import zarr
36
+ from zarr.errors import GroupNotFoundError, NodeNotFoundError
37
+ from zarr.storage import LocalStore
38
+
39
+ from firecube.core.errors import SchemaDriftError
40
+ from firecube.core.uris import is_remote_target, local_path_from_target
41
+ from firecube.core.zarr._reserved_attrs import assert_attrs_safe
42
+
43
+ log = logging.getLogger("firecube.core.zarr.region_writer")
44
+
45
+ _DEFAULT_COORD_NAMES: frozenset[str] = frozenset({"y", "x", "channel"})
46
+
47
+
48
+ def _raise_time_capacity_error(
49
+ group_name: str,
50
+ array_name: str,
51
+ *,
52
+ length: int,
53
+ ts_index: int,
54
+ ) -> None:
55
+ raise ValueError(
56
+ f"Array {group_name}/{array_name} has time dimension length {length}; "
57
+ f"ts_index={ts_index} is out of bounds. Preallocate timestamped arrays "
58
+ "with ZarrArraySpec.expected_time_count before writing."
59
+ )
60
+
61
+
62
+ def _fill_value_is_missing(value: Any) -> bool:
63
+ """Return True when *value* is a not-a-value sentinel for its dtype.
64
+
65
+ Covers floating/complex ``NaN`` and datetime64/timedelta64 ``NaT`` — the fill
66
+ sentinels that are never equal to themselves under ``==``. Dispatch is by
67
+ numpy dtype kind so every such type is handled uniformly; all other kinds
68
+ (ints, bytes, object, ``None``) are never "missing".
69
+ """
70
+ arr = np.asarray(value)
71
+ if arr.dtype.kind in ("f", "c"): # floating, complex
72
+ return bool(np.isnan(arr))
73
+ if arr.dtype.kind in ("M", "m"): # datetime64, timedelta64
74
+ return bool(np.isnat(arr))
75
+ return False
76
+
77
+
78
+ def _fill_values_equal(existing: Any, spec: Any) -> bool:
79
+ # Missing-value fills (NaN, NaT) are never == themselves, so compare them by
80
+ # "both missing" rather than by value; otherwise fall back to plain equality.
81
+ existing_missing = _fill_value_is_missing(existing)
82
+ spec_missing = _fill_value_is_missing(spec)
83
+ if existing_missing or spec_missing:
84
+ return existing_missing and spec_missing
85
+ return bool(existing == spec)
86
+
87
+
88
+ def _array_is_all_fill(arr: np.ndarray, fill_value: Any) -> bool:
89
+ """Return True iff every element of *arr* equals *fill_value* under
90
+ missing-aware equality.
91
+
92
+ Mirrors :func:`_fill_value_is_missing` semantics:
93
+ - float / complex NaN fill: every element must be NaN
94
+ - datetime64 / timedelta64 NaT fill: every element must be NaT
95
+ - all other dtypes (int, bool, bytes/string): plain element-wise equality
96
+
97
+ Empty arrays return True (consistent with ``np.all([])`` semantics; an
98
+ empty static array trivially satisfies "all elements equal fill".
99
+
100
+ Out of contract: object dtype, structured/record dtypes. Callers MUST
101
+ not pass these.
102
+ """
103
+ if _fill_value_is_missing(fill_value):
104
+ kind = np.asarray(fill_value).dtype.kind
105
+ if kind in ("f", "c"):
106
+ return bool(np.all(np.isnan(arr)))
107
+ if kind in ("M", "m"):
108
+ return bool(np.all(np.isnat(arr)))
109
+ return bool(np.all(arr == fill_value))
110
+
111
+
112
+ _ARRAY_EQUAL_NAN_UNSAFE_KINDS = frozenset({"U", "S", "O", "V"})
113
+
114
+
115
+ def _arrays_equal_missing_aware(a1: Any, a2: Any) -> bool:
116
+ """Element-wise array equality with NaN/NaT-aware semantics.
117
+
118
+ Uses ``np.array_equal(..., equal_nan=True)`` for numeric and temporal
119
+ dtypes (``i``, ``u``, ``b``, ``f``, ``c``, ``M``, ``m``), which treats
120
+ NaN==NaN as equal for float/complex and NaT==NaT as equal for
121
+ datetime64/timedelta64.
122
+
123
+ Falls back to plain ``np.array_equal`` for dtypes (``U``, ``S``, ``O``,
124
+ ``V``) where NumPy's ``equal_nan=True`` raises ``TypeError`` because
125
+ ``np.isnan`` has no loop for those kinds. There is no NaN concept for
126
+ strings/bytes/objects, and structured arrays need element-wise field
127
+ comparison that is out of scope here.
128
+
129
+ Both operand dtypes are checked because the on-disk dtype (``a1``) may
130
+ differ from the incoming intent dtype (``a2``) when one side has been
131
+ coerced through serialization.
132
+ """
133
+ left = np.asarray(a1)
134
+ right = np.asarray(a2)
135
+ if (
136
+ left.dtype.kind in _ARRAY_EQUAL_NAN_UNSAFE_KINDS
137
+ or right.dtype.kind in _ARRAY_EQUAL_NAN_UNSAFE_KINDS
138
+ ):
139
+ return bool(np.array_equal(left, right))
140
+ return bool(np.array_equal(left, right, equal_nan=True))
141
+
142
+
143
+ def _array_path_exists(root: Any, group_path: str) -> bool:
144
+ """Return True if ``group_path`` (e.g. ``data/counts``) resolves to an array."""
145
+ parts = [p for p in group_path.split("/") if p]
146
+ if not parts:
147
+ return False
148
+ current = root
149
+ for part in parts[:-1]:
150
+ if part not in current:
151
+ return False
152
+ current = current[part]
153
+ return parts[-1] in current
154
+
155
+
156
+ def _open_root_read_only(writer: RegionZarrWriter) -> Any | None:
157
+ """Open the root group without creating missing Zarr metadata."""
158
+ try:
159
+ if writer._store is not None:
160
+ return zarr.open_group(store=writer._store, mode="r", zarr_format=3)
161
+ if not is_remote_target(writer._store_uri):
162
+ store = LocalStore(str(local_path_from_target(writer._store_uri)))
163
+ return zarr.open_group(store=store, mode="r", zarr_format=3)
164
+ return zarr.open_group(store=writer._store_uri, mode="r", zarr_format=3)
165
+ except (FileNotFoundError, GroupNotFoundError, NodeNotFoundError):
166
+ return None
167
+
168
+
169
+ def group_schema_satisfied(
170
+ writer: RegionZarrWriter,
171
+ group_name: str,
172
+ arr_specs: Sequence[Any],
173
+ expected_time_count: int,
174
+ ) -> bool:
175
+ """Return True iff every array already exists and matches its spec (read-only).
176
+
177
+ A pure read check (no writes, no claims). Schema setup is idempotent, so when
178
+ this returns True the caller can skip the exclusive setup claim entirely —
179
+ which is what lets concurrent slot-range pods start at once without racing on
180
+ an idempotent verify. Returns False when any array is missing or drifts; the
181
+ caller then takes the claim and the existing create/verify path surfaces real
182
+ drift as usual.
183
+ """
184
+ root = _open_root_read_only(writer)
185
+ if root is None:
186
+ return False
187
+ for arr_spec in arr_specs:
188
+ group_path = f"{group_name}/{arr_spec.name}"
189
+ if not _array_path_exists(root, group_path):
190
+ return False
191
+ try:
192
+ writer.verify_array_spec(group_path, arr_spec, expected_time_count)
193
+ except SchemaDriftError:
194
+ return False
195
+ return True
196
+
197
+
198
+ @runtime_checkable
199
+ class RegionZarrWriterProtocol(Protocol):
200
+ """Structural interface for direct-Zarr region writers.
201
+
202
+ Plugins and templates may depend on this protocol without coupling to the
203
+ concrete ``RegionZarrWriter`` implementation.
204
+ """
205
+
206
+ def ensure_group(
207
+ self,
208
+ group: str,
209
+ shape: tuple[int, ...] | None = None,
210
+ dtype: np.dtype[Any] | type[np.generic] | type[Any] | str | None = None,
211
+ fill_value: Any | None = None,
212
+ chunks: tuple[int, ...] | None = None,
213
+ allow_grow: bool = False,
214
+ shards: tuple[int, ...] | None = None,
215
+ attrs: Mapping[str, Any] | None = None,
216
+ dimension_names: tuple[str, ...] | None = None,
217
+ ) -> Any: ...
218
+
219
+ def ensure_timestamp_slot(self, group: str, ts_index: int) -> None: ...
220
+
221
+ def write_region(
222
+ self,
223
+ group: str,
224
+ array_name: str,
225
+ ts_index: int,
226
+ y_slice: slice,
227
+ data: np.ndarray,
228
+ *,
229
+ channel_index: int | None = None,
230
+ ) -> None: ...
231
+
232
+ def write_1d(
233
+ self,
234
+ group: str,
235
+ array_name: str,
236
+ ts_index: int,
237
+ data: np.ndarray,
238
+ ) -> None: ...
239
+
240
+ def resolve_timestamp_index(self, group: str, timestamp_val: Any) -> int: ...
241
+
242
+ def write_timestamp(
243
+ self,
244
+ group: str,
245
+ ts_index: int,
246
+ timestamp_val: Any,
247
+ ) -> None: ...
248
+
249
+ def write_static(
250
+ self,
251
+ group: str,
252
+ array_name: str,
253
+ data: np.ndarray,
254
+ ) -> None: ...
255
+
256
+
257
+ class RegionZarrWriter:
258
+ """Write arrays directly to a Zarr store with lazy open semantics.
259
+
260
+ Parameters
261
+ ----------
262
+ store_uri:
263
+ Target store URI/path (local path, ``file://`` URI, or ``s3://`` URI).
264
+ coord_names:
265
+ Array names treated as coordinate axes. These are skipped by
266
+ :meth:`ensure_timestamp_slot` when resizing timestamped arrays.
267
+ Defaults to ``{"y", "x", "channel"}``.
268
+ """
269
+
270
+ def __init__(
271
+ self,
272
+ store_uri: str,
273
+ *,
274
+ store: object | None = None,
275
+ coord_names: frozenset[str] = _DEFAULT_COORD_NAMES,
276
+ time_coord_name: str = "timestamp",
277
+ ) -> None:
278
+ self._store_uri = store_uri
279
+ self._store = store # pre-built store (e.g., zarr.storage.ObjectStore for obstore)
280
+ self._coord_names = coord_names
281
+ self._time_coord_name = time_coord_name
282
+ self._root: Any | None = None
283
+
284
+ def _open_root(self) -> Any:
285
+ """Open and memoize the root group in append mode."""
286
+ if self._root is not None:
287
+ return self._root
288
+
289
+ if self._store is not None:
290
+ self._root = zarr.open_group(store=self._store, mode="a", zarr_format=3)
291
+ return self._root
292
+
293
+ if not is_remote_target(self._store_uri):
294
+ store = LocalStore(str(local_path_from_target(self._store_uri)))
295
+ self._root = zarr.open_group(store=store, mode="a", zarr_format=3)
296
+ else:
297
+ self._root = zarr.open_group(store=self._store_uri, mode="a", zarr_format=3)
298
+ return self._root
299
+
300
+ @staticmethod
301
+ def _normalize_timestamp_value(timestamp_val: Any) -> np.datetime64:
302
+ """Normalize timestamp-like values to ``datetime64[s]``."""
303
+ if isinstance(timestamp_val, np.datetime64):
304
+ return timestamp_val.astype("datetime64[s]")
305
+ if hasattr(timestamp_val, "isoformat"):
306
+ return np.datetime64(str(timestamp_val.isoformat()), "s")
307
+ return np.datetime64(str(timestamp_val), "s")
308
+
309
+ def ensure_group(
310
+ self,
311
+ group: str,
312
+ shape: tuple[int, ...] | None = None,
313
+ dtype: np.dtype[Any] | type[np.generic] | type[Any] | str | None = None,
314
+ fill_value: Any | None = None,
315
+ chunks: tuple[int, ...] | None = None,
316
+ allow_grow: bool = False,
317
+ shards: tuple[int, ...] | None = None,
318
+ attrs: Mapping[str, Any] | None = None,
319
+ dimension_names: tuple[str, ...] | None = None,
320
+ ) -> Any:
321
+ """Ensure a group or array path exists.
322
+
323
+ Parameters
324
+ ----------
325
+ group:
326
+ Group path or array path (e.g. ``data_1km/counts``).
327
+ shape:
328
+ Array shape when creating array paths.
329
+ dtype:
330
+ Array dtype when creating array paths.
331
+ fill_value:
332
+ Fill value for newly created arrays.
333
+ chunks:
334
+ Chunk shape for newly created arrays.
335
+ allow_grow:
336
+ If ``True``, grow existing arrays to at least *shape*.
337
+ Defaults to ``False`` so schema verification callers can inspect
338
+ undersized arrays and raise their own drift errors.
339
+ shards:
340
+ Optional Zarr v3 shard shape for newly created arrays.
341
+ attrs:
342
+ Optional user attributes to write at array creation. Reserved keys
343
+ (see :data:`firecube.core.zarr._reserved_attrs.RESERVED_ARRAY_ATTRS`)
344
+ are rejected. ``ensure_group`` is creation-only — drift detection
345
+ for the attrs of existing arrays is handled by
346
+ :meth:`verify_array_spec`.
347
+ dimension_names:
348
+ Optional Zarr v3 ``dimension_names`` for newly created arrays. On
349
+ resume, a mismatch against an existing array raises
350
+ :class:`SchemaDriftError`; firecube does not perform in-place
351
+ dimension renames.
352
+
353
+ Returns
354
+ -------
355
+ Existing or newly created group/array.
356
+
357
+ Raises
358
+ ------
359
+ ValueError
360
+ If array creation is requested without *shape*/*dtype*, or if
361
+ *attrs* contains reserved keys.
362
+ SchemaDriftError
363
+ If *dimension_names* differs from the existing array's
364
+ ``dimension_names`` on resume.
365
+ """
366
+ if attrs is not None:
367
+ assert_attrs_safe(attrs)
368
+
369
+ root = self._open_root()
370
+ parts = [p for p in Path(group).parts if p not in ("", "/")]
371
+ if not parts:
372
+ return root
373
+
374
+ if len(parts) == 1 and shape is None:
375
+ return root.require_group(parts[0])
376
+
377
+ if shape is None or dtype is None:
378
+ raise ValueError("shape and dtype are required when ensuring an array path")
379
+
380
+ grp_path = "/".join(parts[:-1])
381
+ arr_name = parts[-1]
382
+ target_group = root if not grp_path else root.require_group(grp_path)
383
+ if arr_name in target_group:
384
+ arr = target_group[arr_name]
385
+ if (
386
+ allow_grow
387
+ and len(arr.shape) == len(shape)
388
+ and any(
389
+ current < requested for current, requested in zip(arr.shape, shape, strict=True)
390
+ )
391
+ ):
392
+ arr.resize(
393
+ tuple(
394
+ max(current, requested)
395
+ for current, requested in zip(arr.shape, shape, strict=True)
396
+ )
397
+ )
398
+ if dimension_names is not None:
399
+ existing_dimnames = getattr(getattr(arr, "metadata", None), "dimension_names", None)
400
+ if existing_dimnames is not None and tuple(existing_dimnames) != tuple(
401
+ dimension_names
402
+ ):
403
+ raise SchemaDriftError(
404
+ f"Schema drift for {group!r} field='dimension_names': "
405
+ f"existing={tuple(existing_dimnames)!r} spec={tuple(dimension_names)!r}. "
406
+ "Re-ingest from scratch; no in-place migration is provided."
407
+ )
408
+ return arr
409
+
410
+ kwargs: dict[str, Any] = {
411
+ "name": arr_name,
412
+ "shape": shape,
413
+ "dtype": dtype,
414
+ "fill_value": fill_value,
415
+ }
416
+ if chunks is not None:
417
+ kwargs["chunks"] = chunks
418
+ if shards is not None:
419
+ kwargs["shards"] = shards
420
+ if attrs is not None:
421
+ kwargs["attributes"] = dict(attrs)
422
+ if dimension_names is not None:
423
+ kwargs["dimension_names"] = list(dimension_names)
424
+ return target_group.create_array(**kwargs)
425
+
426
+ def set_group_attrs(self, group: str, attrs: Mapping[str, Any] | None) -> None:
427
+ """Stamp convention-agnostic group-level attributes onto the group metadata.
428
+
429
+ Writes ``attrs`` verbatim onto the Zarr group's ``zarr.json``; the writer
430
+ does not interpret the mapping. Reserved firecube-internal attribute names
431
+ are rejected (see :func:`assert_attrs_safe`). A falsy ``attrs`` is a no-op.
432
+ Idempotent — re-stamping the same attrs is safe.
433
+ """
434
+ if not attrs:
435
+ return
436
+ assert_attrs_safe(attrs)
437
+ root = self._open_root()
438
+ parts = [p for p in Path(group).parts if p not in ("", "/")]
439
+ target = root if not parts else root.require_group("/".join(parts))
440
+ target.attrs.update(dict(attrs))
441
+
442
+ def verify_array_spec(self, group_path: str, spec: Any, expected_time_count: int) -> None:
443
+ root = self._open_root()
444
+ arr = root[group_path]
445
+
446
+ def _fail(field: str, existing: Any, expected: Any, hint: str) -> None:
447
+ raise SchemaDriftError(
448
+ f"Schema drift for group_path={group_path!r} field={field!r}: "
449
+ f"existing={existing!r} spec={expected!r}. {hint}"
450
+ )
451
+
452
+ existing_dtype = np.dtype(arr.dtype).str
453
+ spec_dtype = np.dtype(spec.dtype).str
454
+ if existing_dtype != spec_dtype:
455
+ _fail(
456
+ "dtype",
457
+ existing_dtype,
458
+ spec_dtype,
459
+ "Align the plugin schema with the existing array.",
460
+ )
461
+
462
+ existing_rank = int(getattr(arr, "ndim", len(getattr(arr, "shape", ()))))
463
+ spec_rank = len(spec.shape)
464
+ if existing_rank != spec_rank:
465
+ _fail("rank", existing_rank, spec_rank, "Rank must match the declared array spec.")
466
+
467
+ existing_shape_tail = tuple(arr.shape[1:])
468
+ spec_shape_tail = tuple(spec.shape[1:])
469
+ if existing_shape_tail != spec_shape_tail:
470
+ _fail(
471
+ "shape[1:]",
472
+ existing_shape_tail,
473
+ spec_shape_tail,
474
+ "Non-time dimensions must match exactly.",
475
+ )
476
+
477
+ if getattr(spec, "time_indexed", True):
478
+ existing_time_count = int(arr.shape[0]) if arr.ndim else 0
479
+ if existing_time_count < expected_time_count:
480
+ _fail(
481
+ "shape[0]",
482
+ existing_time_count,
483
+ expected_time_count,
484
+ "Existing arrays smaller than the global expected time count must be recreated.",
485
+ )
486
+ if existing_time_count > expected_time_count:
487
+ log.warning(
488
+ "Schema drift tolerated for group_path=%s field=shape[0]: existing=%s spec=%s; over-allocation is benign.",
489
+ group_path,
490
+ existing_time_count,
491
+ expected_time_count,
492
+ )
493
+
494
+ if spec.chunks is not None:
495
+ existing_chunks = tuple(arr.chunks) if arr.chunks is not None else None
496
+ if existing_chunks != tuple(spec.chunks):
497
+ _fail(
498
+ "chunks",
499
+ existing_chunks,
500
+ tuple(spec.chunks),
501
+ "Recreate the array with matching chunking.",
502
+ )
503
+
504
+ if spec.fill_value is not None:
505
+ existing_fill_value = arr.fill_value
506
+ spec_fill_value = spec.fill_value
507
+ if not _fill_values_equal(existing_fill_value, spec_fill_value):
508
+ _fail(
509
+ "fill_value",
510
+ existing_fill_value,
511
+ spec_fill_value,
512
+ "Use the same fill value when declaring the schema.",
513
+ )
514
+
515
+ if spec.shards is not None:
516
+ existing_shards = getattr(arr, "shards", None)
517
+ if existing_shards is not None:
518
+ existing_shards_tuple = tuple(existing_shards)
519
+ if existing_shards_tuple != tuple(spec.shards):
520
+ _fail(
521
+ "shards",
522
+ existing_shards_tuple,
523
+ tuple(spec.shards),
524
+ "Recreate the array with matching shards.",
525
+ )
526
+
527
+ if spec.attrs is not None:
528
+ assert_attrs_safe(spec.attrs)
529
+ for k, v in spec.attrs.items():
530
+ existing_v = arr.attrs.get(k)
531
+ if existing_v != v:
532
+ _fail(
533
+ f"attrs[{k!r}]",
534
+ existing_v,
535
+ v,
536
+ "Use the same attrs when declaring the schema.",
537
+ )
538
+
539
+ if spec.dimension_names is not None:
540
+ existing_dimnames = getattr(getattr(arr, "metadata", None), "dimension_names", None)
541
+ if existing_dimnames is not None and tuple(existing_dimnames) != tuple(
542
+ spec.dimension_names
543
+ ):
544
+ _fail(
545
+ "dimension_names",
546
+ tuple(existing_dimnames),
547
+ tuple(spec.dimension_names),
548
+ "Re-ingest from scratch; no in-place dimension rename is supported.",
549
+ )
550
+
551
+ def ensure_timestamp_slot(self, group: str, ts_index: int) -> None:
552
+ """Validate that all timestamped arrays in a group include *ts_index*.
553
+
554
+ Arrays whose names appear in ``coord_names`` (constructor param) or
555
+ that are scalar are skipped.
556
+
557
+ Parameters
558
+ ----------
559
+ group:
560
+ Data group path (e.g. ``data_1km``).
561
+ ts_index:
562
+ Zero-based timestamp index that must exist.
563
+ """
564
+ grp = self._open_root()[group]
565
+ for name in list(grp.array_keys()):
566
+ if name in self._coord_names:
567
+ continue
568
+ arr = grp[name]
569
+ if arr.ndim == 0:
570
+ continue
571
+ if arr.shape[0] > ts_index:
572
+ continue
573
+ _raise_time_capacity_error(group, name, length=int(arr.shape[0]), ts_index=ts_index)
574
+
575
+ def write_region(
576
+ self,
577
+ group: str,
578
+ array_name: str,
579
+ ts_index: int,
580
+ y_slice: slice,
581
+ data: np.ndarray,
582
+ *,
583
+ channel_index: int | None = None,
584
+ ) -> None:
585
+ """Write one spatial region for one timestamp.
586
+
587
+ Parameters
588
+ ----------
589
+ group:
590
+ Data group path.
591
+ array_name:
592
+ Target array name inside *group*.
593
+ ts_index:
594
+ Timestamp slot index.
595
+ y_slice:
596
+ Spatial y-range to write.
597
+ data:
598
+ Input data with matching shape.
599
+ channel_index:
600
+ Optional channel index for 4-D arrays.
601
+
602
+ Raises
603
+ ------
604
+ ValueError
605
+ If target array rank is unsupported.
606
+ """
607
+ arr = self._open_root()[f"{group}/{array_name}"]
608
+ if arr.ndim == 4:
609
+ if channel_index is None:
610
+ arr[ts_index, y_slice, :, :] = data
611
+ else:
612
+ arr[ts_index, y_slice, :, channel_index] = data
613
+ return
614
+ if arr.ndim == 3:
615
+ arr[ts_index, y_slice, :] = data
616
+ return
617
+ raise ValueError(f"Unsupported array rank for write_region: {arr.ndim}")
618
+
619
+ def write_1d(
620
+ self,
621
+ group: str,
622
+ array_name: str,
623
+ ts_index: int,
624
+ data: np.ndarray,
625
+ ) -> None:
626
+ """Write exactly one timestamp slot.
627
+
628
+ For 1-D target arrays, the slot is the single element at ``ts_index``
629
+ and ``data`` must contain exactly one value (0-D scalar or 1-element
630
+ ndarray). For higher-rank target arrays, the slot is ``arr[ts_index]``
631
+ and ``data`` must match ``arr.shape[1:]``.
632
+
633
+ Uses the one-element slice-assign idiom ``arr[i:i+1] = payload.reshape(1)``
634
+ for 1-D targets: under ``numpy>=2`` (pinned in ``pyproject.toml``),
635
+ ``arr[i] = one_element_ndarray`` raises ``ValueError`` for typed dtypes
636
+ (misleadingly as "Could not convert object to NumPy datetime" for
637
+ datetime64). Slice-assign is numpy 1.x AND 2.x safe. Writing multi-element
638
+ payloads to 1-D targets is REJECTED — the control-plane records writes
639
+ at ``(group, ts_index)`` granularity only (see DESIGN.md §"Risks To
640
+ Avoid"), so multi-slot payloads would create silent data-integrity gaps.
641
+
642
+ Parameters
643
+ ----------
644
+ group:
645
+ Data group path.
646
+ array_name:
647
+ Target array name.
648
+ ts_index:
649
+ Timestamp slot index.
650
+ data:
651
+ One value to write at the slot. Shape/rank constraints depend on
652
+ the target array's rank (see contract above).
653
+
654
+ Raises
655
+ ------
656
+ ValueError
657
+ If a 1-D target receives a payload with ``size != 1``, or if a
658
+ higher-rank target receives a payload whose shape does not match
659
+ ``arr.shape[1:]``.
660
+ """
661
+ arr = self._open_root()[f"{group}/{array_name}"]
662
+ payload = np.asarray(data)
663
+
664
+ if arr.ndim == 1:
665
+ if payload.ndim == 0:
666
+ arr[ts_index : ts_index + 1] = payload.reshape(1)
667
+ return
668
+ if payload.size != 1:
669
+ raise ValueError(
670
+ f"write_1d writes exactly one slot for 1-D targets; got "
671
+ f"payload with size={payload.size} for "
672
+ f"{group}/{array_name}"
673
+ )
674
+ arr[ts_index : ts_index + 1] = payload.reshape(1)
675
+ return
676
+
677
+ if payload.shape != tuple(arr.shape[1:]):
678
+ raise ValueError(
679
+ f"write_1d payload shape {payload.shape} does not match "
680
+ f"target arr.shape[1:]={tuple(arr.shape[1:])} for "
681
+ f"{group}/{array_name}"
682
+ )
683
+ arr[ts_index] = payload
684
+
685
+ def resolve_timestamp_index(self, group: str, timestamp_val: Any) -> int:
686
+ """Return the existing or next compact timestamp index for *group*.
687
+
688
+ If a matching timestamp already exists in the ``timestamp`` array,
689
+ its index is returned (idempotent). Otherwise the next available
690
+ slot (== current length) is returned.
691
+ """
692
+ root = self._open_root()
693
+ self.ensure_group(group)
694
+ path = f"{group}/{self._time_coord_name}"
695
+ normalized = self._normalize_timestamp_value(timestamp_val)
696
+
697
+ if path not in root:
698
+ return 0
699
+
700
+ timestamp_arr = root[path]
701
+ if timestamp_arr.shape[0] == 0:
702
+ return 0
703
+
704
+ existing = np.asarray(timestamp_arr[:])
705
+ matches = np.nonzero(existing == normalized)[0]
706
+ if matches.size:
707
+ return int(matches[0])
708
+ return int(timestamp_arr.shape[0])
709
+
710
+ def write_timestamp(
711
+ self,
712
+ group: str,
713
+ ts_index: int,
714
+ timestamp_val: Any,
715
+ ) -> None:
716
+ """Ensure and write the timestamp coordinate for one slot."""
717
+ normalized = self._normalize_timestamp_value(timestamp_val)
718
+ timestamp_arr = self.ensure_group(
719
+ f"{group}/{self._time_coord_name}",
720
+ shape=(ts_index + 1,),
721
+ dtype=normalized.dtype,
722
+ fill_value=np.datetime64("NaT", "s"),
723
+ chunks=(max(1, min(256, ts_index + 1)),),
724
+ allow_grow=True,
725
+ dimension_names=(self._time_coord_name,),
726
+ )
727
+ self.ensure_group(group)
728
+ self.ensure_timestamp_slot(group, ts_index)
729
+ if hasattr(timestamp_arr, "shape"):
730
+ if timestamp_arr.shape[0] <= ts_index:
731
+ _raise_time_capacity_error(
732
+ group,
733
+ self._time_coord_name,
734
+ length=int(timestamp_arr.shape[0]),
735
+ ts_index=ts_index,
736
+ )
737
+ timestamp_arr[ts_index] = normalized
738
+
739
+ def write_static(
740
+ self,
741
+ group: str,
742
+ array_name: str,
743
+ data: np.ndarray,
744
+ ) -> None:
745
+ """Write a static (non-time-indexed) array at its declared shape.
746
+
747
+ The array must already exist (created by schema setup).
748
+ This method performs a full overwrite: ``arr[...] = data``.
749
+ It does NOT call ``ensure_timestamp_slot`` — static arrays have no time axis.
750
+
751
+ Parameters
752
+ ----------
753
+ group:
754
+ Data group path.
755
+ array_name:
756
+ Target array name.
757
+ data:
758
+ Data to assign. Must have the same ndim and shape as the preallocated array.
759
+
760
+ Raises
761
+ ------
762
+ KeyError:
763
+ If the array does not exist in the store.
764
+ ValueError:
765
+ If ``data.ndim`` or ``data.shape`` does not match the stored array.
766
+ """
767
+ root = self._open_root()
768
+ arr_path = f"{group}/{array_name}"
769
+ if arr_path not in root:
770
+ raise KeyError(
771
+ f"Array {arr_path!r} does not exist in store. Create it via schema setup first."
772
+ )
773
+ arr = root[arr_path]
774
+ if arr.ndim != data.ndim:
775
+ raise ValueError(
776
+ f"Array {arr_path!r} ndim mismatch: stored={arr.ndim}, data={data.ndim}"
777
+ )
778
+ if arr.shape != data.shape:
779
+ raise ValueError(
780
+ f"Array {arr_path!r} shape mismatch: stored={arr.shape}, data={data.shape}"
781
+ )
782
+ arr[...] = data