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,425 @@
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
+ """Engine-owned boundary for recording ingestion lifecycle into ChunkManager state."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import threading
21
+ from datetime import datetime
22
+ from typing import Any
23
+
24
+ from firecube.core.controlplane import ChunkManager, SpanCoverage
25
+ from firecube.core.errors import ManifestError
26
+ from firecube.ingestor.types.context import (
27
+ IngestResult,
28
+ PipelineBatch,
29
+ PipelineResult,
30
+ PluginContext,
31
+ RuntimeIngestContext,
32
+ )
33
+ from firecube.ingestor.types.result_metrics import ResultMetrics
34
+
35
+
36
+ class SpanRecorder:
37
+ """Map engine lifecycle events into ChunkManager's WAL-backed control-plane records."""
38
+
39
+ def __init__(self, chunk_manager: ChunkManager):
40
+ self._chunk_manager = chunk_manager
41
+ self._log = logging.getLogger("firecube.ingestor.recording")
42
+
43
+ def register_run_started(
44
+ self,
45
+ *,
46
+ run_id: str,
47
+ product: str,
48
+ output_path: str,
49
+ output_format: str,
50
+ slice_meta: dict[str, Any],
51
+ slot_range: tuple[int, int] | None = None,
52
+ slot_group: str | None = None,
53
+ ) -> None:
54
+ """Record the engine-owned non-terminal start event for one run."""
55
+ if threading.current_thread() is not threading.main_thread():
56
+ raise ManifestError("Run start registration must occur on the main thread.")
57
+ meta = dict(slice_meta)
58
+ meta["run_id"] = run_id
59
+ self._chunk_manager.record_run_started(
60
+ product=product,
61
+ run_id=run_id,
62
+ output_path=output_path,
63
+ output_format=output_format,
64
+ size=0,
65
+ meta=meta,
66
+ slot_range=slot_range,
67
+ slot_group=slot_group,
68
+ )
69
+
70
+ def register_run(
71
+ self,
72
+ ctx: RuntimeIngestContext,
73
+ result: IngestResult,
74
+ run_id: str,
75
+ product: str,
76
+ slice_meta: dict[str, Any],
77
+ record_spans: bool = True,
78
+ slot_range: tuple[int, int] | None = None,
79
+ slot_group: str | None = None,
80
+ ) -> None:
81
+ """Record the terminal run event and any final span coverage through ChunkManager."""
82
+ _ = ctx
83
+ if threading.current_thread() is not threading.main_thread():
84
+ raise ManifestError("Run registration must occur on the main thread.")
85
+
86
+ if result.registered:
87
+ self._log.warning("Run %s is already registered, skipping.", run_id)
88
+ return
89
+
90
+ meta = dict(slice_meta)
91
+ coverage = _span_coverage_from_metrics(
92
+ result.metrics,
93
+ logger=self._log,
94
+ context="run registration",
95
+ )
96
+ time_min, time_max = _time_bounds_from_coverage(coverage)
97
+ if time_min:
98
+ meta["time_min"] = time_min
99
+ if time_max:
100
+ meta["time_max"] = time_max
101
+
102
+ storage_bytes: int = 0
103
+ storage_summary = result.metrics.storage
104
+ if storage_summary is not None:
105
+ storage_bytes = storage_summary.bytes
106
+
107
+ self._chunk_manager.record_run_terminal(
108
+ product=product,
109
+ run_id=run_id,
110
+ output_path=str(result.outputs.primary),
111
+ output_format=result.output_format or "unknown",
112
+ size=storage_bytes,
113
+ meta=meta,
114
+ status="complete",
115
+ slot_range=slot_range,
116
+ slot_group=slot_group,
117
+ )
118
+ post_terminal_events_recorded = False
119
+ prior_spans: list[Any] = []
120
+
121
+ if ctx.force_reingest:
122
+ prior_spans = _list_prior_active_spans_for_replacement(
123
+ self._chunk_manager,
124
+ product=product,
125
+ run_id=run_id,
126
+ slice_meta=meta,
127
+ )
128
+ if prior_spans and not coverage:
129
+ raise ManifestError(
130
+ f"force_reingest refusing to commit replacement for product={product!r}: "
131
+ f"prior_spans={len(prior_spans)} but new coverage is empty. "
132
+ "This would erase active coverage. "
133
+ f"Abandon the run first: firecube chunks runs abandon {run_id}"
134
+ )
135
+
136
+ if record_spans and coverage:
137
+ base_meta = dict(meta)
138
+ base_meta["run_id"] = run_id
139
+ for cov in coverage:
140
+ span_meta = dict(base_meta)
141
+ if cov.time_min:
142
+ span_meta["time_min"] = cov.time_min
143
+ if cov.time_max:
144
+ span_meta["time_max"] = cov.time_max
145
+ self._chunk_manager.record_span(
146
+ product=product,
147
+ run_id=str(run_id),
148
+ batch_id="single",
149
+ group=cov.group,
150
+ status="active",
151
+ coverage=cov,
152
+ meta=span_meta,
153
+ )
154
+ post_terminal_events_recorded = True
155
+
156
+ if ctx.force_reingest and prior_spans:
157
+ self._chunk_manager.record_replacement_committed( # pyright: ignore[reportAttributeAccessIssue]
158
+ product=product,
159
+ run_id=run_id,
160
+ replacing_run_id=run_id,
161
+ replaced_span_keys=[span.key for span in prior_spans],
162
+ )
163
+ post_terminal_events_recorded = True
164
+
165
+ if post_terminal_events_recorded:
166
+ _rewrite_terminal_run_metadata(self._chunk_manager, product=product, run_id=run_id)
167
+
168
+ result.registered = True
169
+
170
+ def register_run_failure(
171
+ self,
172
+ *,
173
+ run_id: str,
174
+ product: str,
175
+ output_path: str,
176
+ output_format: str,
177
+ slice_meta: dict[str, Any],
178
+ error: str,
179
+ ) -> None:
180
+ """Record a terminal run failure event through ChunkManager."""
181
+ if threading.current_thread() is not threading.main_thread():
182
+ raise ManifestError("Run failure registration must occur on the main thread.")
183
+ meta = dict(slice_meta)
184
+ meta["run_id"] = run_id
185
+ self._chunk_manager.record_run_failed(
186
+ product=product,
187
+ run_id=run_id,
188
+ output_path=output_path,
189
+ output_format=output_format,
190
+ size=0,
191
+ meta=meta,
192
+ error=error,
193
+ )
194
+
195
+ def record_batch_success(
196
+ self,
197
+ ctx: PluginContext,
198
+ batch: PipelineBatch,
199
+ result: PipelineResult,
200
+ slice_meta: dict[str, Any],
201
+ run_id: str,
202
+ product: str,
203
+ ) -> None:
204
+ """Record one successful batch as active or skipped span records."""
205
+ _ = ctx
206
+ base_meta = dict(slice_meta)
207
+ base_meta["run_id"] = run_id
208
+
209
+ coverage_list = _span_coverage_from_metrics(
210
+ result.metrics,
211
+ logger=self._log,
212
+ context=f"batch {batch.batch_id}",
213
+ )
214
+ if not coverage_list:
215
+ for group in batch.groups or ["unknown"]:
216
+ self._chunk_manager.record_span(
217
+ product=product,
218
+ run_id=run_id,
219
+ batch_id=batch.batch_id,
220
+ group=group,
221
+ status="skipped",
222
+ reason="No coverage generated",
223
+ meta=base_meta,
224
+ )
225
+ return
226
+
227
+ for cov in coverage_list:
228
+ meta = dict(base_meta)
229
+ if cov.time_min:
230
+ meta["time_min"] = cov.time_min
231
+ if cov.time_max:
232
+ meta["time_max"] = cov.time_max
233
+ self._chunk_manager.record_span(
234
+ product=product,
235
+ run_id=run_id,
236
+ batch_id=batch.batch_id,
237
+ group=cov.group,
238
+ status="active",
239
+ coverage=cov,
240
+ meta=meta,
241
+ )
242
+
243
+ def record_batch_failure(
244
+ self,
245
+ ctx: PluginContext,
246
+ batch: PipelineBatch,
247
+ error: str | None,
248
+ slice_meta: dict[str, Any],
249
+ run_id: str,
250
+ product: str,
251
+ ) -> None:
252
+ """Record one failed batch as failed span records."""
253
+ _ = ctx
254
+ self._log.error("Batch %s failed: %s", batch.batch_id, error)
255
+
256
+ base_meta = dict(slice_meta)
257
+ base_meta["run_id"] = run_id
258
+ for group in batch.groups or ["unknown"]:
259
+ self._chunk_manager.record_span(
260
+ product=product,
261
+ run_id=run_id,
262
+ batch_id=batch.batch_id,
263
+ group=group,
264
+ status="failed",
265
+ reason=str(error or "Unknown error"),
266
+ meta=base_meta,
267
+ )
268
+
269
+
270
+ def _parse_iso8601(value: str | None) -> datetime | None:
271
+ if not value:
272
+ return None
273
+ try:
274
+ if value.endswith("Z"):
275
+ value = value[:-1] + "+00:00"
276
+ return datetime.fromisoformat(value)
277
+ except Exception:
278
+ return None
279
+
280
+
281
+ def _time_bounds_from_coverage(
282
+ coverage: list[SpanCoverage] | None,
283
+ ) -> tuple[str | None, str | None]:
284
+ if not coverage:
285
+ return None, None
286
+
287
+ min_dt: datetime | None = None
288
+ max_dt: datetime | None = None
289
+ for cov in coverage:
290
+ cov_min = _parse_iso8601(cov.time_min)
291
+ cov_max = _parse_iso8601(cov.time_max)
292
+ if cov_min and (min_dt is None or cov_min < min_dt):
293
+ min_dt = cov_min
294
+ if cov_max and (max_dt is None or cov_max > max_dt):
295
+ max_dt = cov_max
296
+ return (min_dt.isoformat() if min_dt else None, max_dt.isoformat() if max_dt else None)
297
+
298
+
299
+ def _list_prior_active_spans_for_replacement(
300
+ chunk_manager: ChunkManager,
301
+ *,
302
+ product: str,
303
+ run_id: str,
304
+ slice_meta: dict[str, Any],
305
+ ) -> list[Any]:
306
+ """Return current active spans for the same slice, excluding this run."""
307
+ query_meta = {
308
+ key: value
309
+ for key, value in slice_meta.items()
310
+ if key not in {"run_id", "time_min", "time_max"}
311
+ }
312
+ time_min = slice_meta.get("time_min")
313
+ time_max = slice_meta.get("time_max")
314
+
315
+ query_kwargs: dict[str, Any] = {
316
+ "product": product,
317
+ "chunk_type": "span",
318
+ "include_replaced": False,
319
+ }
320
+ if query_meta:
321
+ query_kwargs["meta"] = query_meta
322
+ if time_min and time_max:
323
+ query_kwargs["time_overlaps"] = (str(time_min), str(time_max))
324
+
325
+ return [
326
+ span
327
+ for span in chunk_manager.list_chunks(**query_kwargs)
328
+ if str((span.meta or {}).get("run_id", "") or "") != run_id
329
+ ]
330
+
331
+
332
+ def _rewrite_terminal_run_metadata(
333
+ chunk_manager: ChunkManager,
334
+ *,
335
+ product: str,
336
+ run_id: str,
337
+ ) -> None:
338
+ """Re-finalize run metadata after post-terminal WAL appends."""
339
+ repo = chunk_manager.repo
340
+ writer = repo._writer(product, run_id, resume_existing=True)
341
+ writer.finalize(status="complete")
342
+ repo._writers.pop((product, run_id), None)
343
+
344
+
345
+ def _coverage_from_nested_mapping(metrics: Any) -> Any | None:
346
+ """Locate span coverage in a metrics mapping.
347
+
348
+ Checks the batch-level top-level ``coverage`` key first, then the run-level
349
+ ``zarr.coverage`` / ``pipeline.coverage`` nested locations that
350
+ :func:`merge_batch_metrics` and the pipeline summary populate. Works for
351
+ both plain dicts and the mapping-compatible :class:`ResultMetrics`.
352
+ """
353
+ coverage = metrics.get("coverage")
354
+ if coverage:
355
+ return coverage
356
+ for key in ("zarr", "pipeline"):
357
+ candidate = metrics.get(key)
358
+ if isinstance(candidate, dict):
359
+ nested = candidate.get("coverage")
360
+ if nested:
361
+ return nested
362
+ return None
363
+
364
+
365
+ def _span_coverage_from_metrics(
366
+ metrics: ResultMetrics | dict[str, Any] | None,
367
+ *,
368
+ logger: logging.Logger | None = None,
369
+ context: str = "batch",
370
+ ) -> list[SpanCoverage] | None:
371
+ """Extract SpanCoverage objects from typed result metrics."""
372
+ coverage: Any | None = None
373
+ if metrics is None:
374
+ coverage = None
375
+ elif isinstance(metrics, ResultMetrics):
376
+ coverage = metrics.pipeline.coverage if metrics.pipeline else None
377
+ if not coverage:
378
+ # Run-level aggregation (merge_batch_metrics) stores coverage under
379
+ # ``metrics["zarr"]["coverage"]``; the typed ``pipeline.coverage``
380
+ # only carries per-batch coverage and is empty for run-level metrics
381
+ # (the pipeline field holds the run summary, which has no coverage).
382
+ # Fall back to the nested location so run registration sees it.
383
+ coverage = _coverage_from_nested_mapping(metrics)
384
+ elif isinstance(metrics, dict):
385
+ coverage = _coverage_from_nested_mapping(metrics)
386
+
387
+ if not coverage:
388
+ if logger:
389
+ logger.debug(
390
+ "No span coverage present in metrics during %s.",
391
+ context,
392
+ )
393
+ return None
394
+
395
+ spans: list[SpanCoverage] = []
396
+ for item in coverage:
397
+ if isinstance(item, SpanCoverage):
398
+ spans.append(item)
399
+ elif isinstance(item, dict) and "group" in item:
400
+ spans.append(
401
+ SpanCoverage(
402
+ group=item["group"],
403
+ arrays=list(item.get("arrays", [])),
404
+ time_index_ranges=(
405
+ list(item["time_index_ranges"]) if "time_index_ranges" in item else None
406
+ ),
407
+ aligned=bool(item.get("aligned", True)),
408
+ state_array=item.get("state_array"),
409
+ state_deleted_value=int(item.get("state_deleted_value", 2)),
410
+ time_min=item.get("time_min"),
411
+ time_max=item.get("time_max"),
412
+ region_spec=item.get("region_spec"),
413
+ write_strategy=item.get("write_strategy"),
414
+ time_dim_name=item.get("time_dim_name"),
415
+ )
416
+ )
417
+ if spans:
418
+ return spans
419
+
420
+ if logger:
421
+ logger.debug(
422
+ "Coverage payload during %s contained no usable span records.",
423
+ context,
424
+ )
425
+ return None