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