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,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
|