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,229 @@
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
+ """Plugin scaffolding generator using uv and standards-compliant metadata."""
16
+
17
+ import re
18
+ from importlib.resources import files as _resource_files
19
+ from pathlib import Path
20
+
21
+ from firecube.core.filesystem import ensure_directory
22
+
23
+
24
+ def _load_template(name: str) -> str:
25
+ """Load a scaffold template file by name from the _templates/ package resource."""
26
+ try:
27
+ return (
28
+ _resource_files("firecube.ingestor.devtools._templates")
29
+ .joinpath(name)
30
+ .read_text(encoding="utf-8")
31
+ )
32
+ except FileNotFoundError as exc:
33
+ raise RuntimeError(
34
+ f"Missing scaffold template resource: {name}. Reinstall firecube."
35
+ ) from exc
36
+
37
+
38
+ # PEP 621 compliant pyproject.toml template
39
+ PYPROJECT_TEMPLATE = """[project]
40
+ name = "{package_name}"
41
+ version = "0.1.0"
42
+ description = "Firecube ingestor plugin for {start_case_name}"
43
+ readme = "README.md"
44
+ requires-python = ">=3.12"
45
+ license = {{ text = "{license}" }}
46
+ authors = [
47
+ {{ name = "{author_name}", email = "{author_email}" }}
48
+ ]
49
+ dependencies = [
50
+ {dependencies}
51
+ ]
52
+
53
+ [project.entry-points."firecube.plugins"]
54
+ {plugin_name} = "{import_name}"
55
+
56
+ # [project.entry-points."firecube.plugin_cli"]
57
+ # {plugin_name} = "{import_name}.plugin_cli:cli"
58
+
59
+ [build-system]
60
+ requires = ["hatchling"]
61
+ build-backend = "hatchling.build"
62
+
63
+ [dependency-groups]
64
+ dev = [
65
+ "pytest",
66
+ "ruff",
67
+ "mypy",
68
+ ]
69
+ """
70
+
71
+ PLUGIN_INIT_TEMPLATE = """
72
+ from .ingestor import {class_name}
73
+
74
+ # Importing the package registers the ingestor via @register_ingestor.
75
+ __all__ = ["{class_name}"]
76
+ """
77
+
78
+ TEST_INGESTOR_TEMPLATE = '''"""Tests for {class_name}.
79
+
80
+ This file is intentionally empty. The firecube scaffold cannot guess what your
81
+ plugin does, so it does not generate placeholder tests that might give false
82
+ confidence in coverage. ``pytest`` will collect zero tests from this file
83
+ until you add them.
84
+
85
+ Test ideas to consider once your hooks are implemented:
86
+
87
+ 1. Registration check: import {import_name}, then assert {class_name} is
88
+ present in firecube's AVAILABLE_INGESTORS registry (proves the
89
+ @register_ingestor decorator fired correctly).
90
+ 2. PRODUCT_NAME assertion: verify {class_name}.PRODUCT_NAME equals
91
+ "{plugin_name}".
92
+ 3. Hook contract: instantiate {class_name}, invoke your hook(s) with a
93
+ known input fixture, assert the returned shape / dtype / row count
94
+ matches expectations.
95
+ 4. Edge cases specific to your input format: empty input, partial input,
96
+ malformed input.
97
+
98
+ See docs/concepts/plugins/create-a-plugin.md for full testing guidance.
99
+ """
100
+ '''
101
+
102
+ _DEPS_STRING_PER_TEMPLATE: dict[str, str] = {
103
+ "base": ' "firecube>=0.1.0"',
104
+ "zarr": ' "firecube>=0.1.0",\n "xarray"',
105
+ "parquet": (
106
+ ' "firecube>=0.1.0",\n'
107
+ ' "pyarrow",\n'
108
+ ' # "pandas", # uncomment if your build_dataset returns pandas.DataFrame'
109
+ ),
110
+ "direct_zarr": ' "firecube>=0.1.0",\n "numpy"',
111
+ }
112
+
113
+ _README_SUBSTITUTIONS_PER_TEMPLATE: dict[str, dict[str, str]] = {
114
+ "base": {
115
+ "hook_summary": "_process_batch()",
116
+ "output_format": "zarr",
117
+ "output_extension": "zarr",
118
+ "write_mode_default": "staged",
119
+ },
120
+ "zarr": {
121
+ "hook_summary": "build_dataset()",
122
+ "output_format": "zarr",
123
+ "output_extension": "zarr",
124
+ "write_mode_default": "staged",
125
+ },
126
+ "parquet": {
127
+ "hook_summary": "build_dataset()",
128
+ "output_format": "parquet",
129
+ "output_extension": "parquet",
130
+ "write_mode_default": "staged",
131
+ },
132
+ "direct_zarr": {
133
+ "hook_summary": "zarr_schema() and build_write_intents()",
134
+ "output_format": "zarr",
135
+ "output_extension": "zarr",
136
+ "write_mode_default": "direct",
137
+ },
138
+ }
139
+
140
+ _INGESTOR_TPL_PER_TEMPLATE: dict[str, str] = {
141
+ "base": "ingestor_base.py.tpl",
142
+ "zarr": "ingestor_zarr.py.tpl",
143
+ "parquet": "ingestor_parquet.py.tpl",
144
+ "direct_zarr": "ingestor_direct_zarr.py.tpl",
145
+ }
146
+
147
+
148
+ def to_snake_case(name: str) -> str:
149
+ name = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
150
+ return name.replace("-", "_").replace(" ", "_")
151
+
152
+
153
+ def to_pascal_case(name: str) -> str:
154
+ return "".join(word.title() for word in name.replace("-", "_").split("_"))
155
+
156
+
157
+ def create_plugin_structure(
158
+ name: str,
159
+ target_dir: Path,
160
+ author_name: str = "Firecube Developer",
161
+ author_email: str = "dev@example.com",
162
+ license: str = "MIT",
163
+ template_type: str = "base",
164
+ ) -> Path:
165
+ """Generate a new plugin project structure."""
166
+
167
+ if template_type not in _INGESTOR_TPL_PER_TEMPLATE:
168
+ raise ValueError(
169
+ f"Unknown template type: {template_type}. "
170
+ f"Choices: {list(_INGESTOR_TPL_PER_TEMPLATE.keys())}"
171
+ )
172
+
173
+ plugin_name = to_snake_case(name)
174
+ class_name = f"{to_pascal_case(name)}Ingestor"
175
+ package_name = f"firecube-{plugin_name.replace('_', '-')}"
176
+ import_name = f"firecube_{plugin_name}"
177
+
178
+ root = target_dir / package_name
179
+ src_dir = root / "src" / import_name
180
+
181
+ if root.exists():
182
+ raise FileExistsError(f"Directory {root} already exists")
183
+
184
+ ensure_directory(src_dir)
185
+ ensure_directory(root / "tests")
186
+
187
+ # Write pyproject.toml with per-template dependencies
188
+ (root / "pyproject.toml").write_text(
189
+ PYPROJECT_TEMPLATE.format(
190
+ package_name=package_name,
191
+ start_case_name=to_pascal_case(name),
192
+ plugin_name=plugin_name,
193
+ import_name=import_name,
194
+ author_name=author_name,
195
+ author_email=author_email,
196
+ license=license,
197
+ dependencies=_DEPS_STRING_PER_TEMPLATE[template_type],
198
+ )
199
+ )
200
+
201
+ # Write template-specific README
202
+ readme_tpl = _load_template("readme.md.tpl")
203
+ (root / "README.md").write_text(
204
+ readme_tpl.format(
205
+ start_case_name=to_pascal_case(name),
206
+ plugin_name=plugin_name,
207
+ **_README_SUBSTITUTIONS_PER_TEMPLATE[template_type],
208
+ )
209
+ )
210
+
211
+ # Write source files
212
+ (src_dir / "__init__.py").write_text(PLUGIN_INIT_TEMPLATE.format(class_name=class_name))
213
+
214
+ ingestor_tpl = _load_template(_INGESTOR_TPL_PER_TEMPLATE[template_type])
215
+ (src_dir / "ingestor.py").write_text(
216
+ ingestor_tpl.format(class_name=class_name, plugin_name=plugin_name)
217
+ )
218
+
219
+ # Create test stub (placeholder comments only — no fake tests)
220
+ (root / "tests" / "__init__.py").touch()
221
+ (root / "tests" / "test_ingestor.py").write_text(
222
+ TEST_INGESTOR_TEMPLATE.format(
223
+ class_name=class_name,
224
+ import_name=import_name,
225
+ plugin_name=plugin_name,
226
+ )
227
+ )
228
+
229
+ return root
@@ -0,0 +1,79 @@
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
+ """Exceptions for Firecube Ingestor."""
16
+
17
+ from firecube.core.errors import (
18
+ FirecubeError,
19
+ ManifestError,
20
+ SchemaDriftError,
21
+ StorageError,
22
+ )
23
+
24
+
25
+ class IngestorError(FirecubeError):
26
+ """Base exception for all ingestor-layer errors."""
27
+
28
+
29
+ class ConfigurationError(IngestorError):
30
+ """Configuration validation failed."""
31
+
32
+
33
+ class SchemaSizeMismatchError(IngestorError):
34
+ """Raised when an existing Zarr array's shape is smaller than the global expected size.
35
+
36
+ Existing arrays mismatch the plan. Either delete them or update the plan to match.
37
+ """
38
+
39
+
40
+ class ResumeConflictError(IngestorError):
41
+ """Resume/overwrite conflict detected."""
42
+
43
+
44
+ class RangeOverlapError(ResumeConflictError):
45
+ """Raised when a new slot-range invocation overlaps with an active non-terminal run.
46
+
47
+ Overlapping ranges risk Zarr chunk-boundary corruption.
48
+ Abandon the conflicting run first: firecube chunks runs abandon ...
49
+ """
50
+
51
+
52
+ class StagedMetadataError(IngestorError):
53
+ """Staged metadata seeding failed for an existing staged-write target.
54
+
55
+ Raised by ``seed_staged_store_metadata`` (strict mode) when zarr.json
56
+ metadata cannot be copied from the final target into the temp store.
57
+ """
58
+
59
+
60
+ class WriteIntentRangeError(IngestorError):
61
+ """Raised when a WriteIntent's ts_index falls outside the assigned slot range.
62
+
63
+ This is a correctness violation — the plugin filter is advisory; this error
64
+ is the mandatory backstop. NEVER silently drop out-of-range intents.
65
+ """
66
+
67
+
68
+ __all__ = [
69
+ "ConfigurationError",
70
+ "IngestorError",
71
+ "ManifestError",
72
+ "RangeOverlapError",
73
+ "ResumeConflictError",
74
+ "SchemaDriftError",
75
+ "SchemaSizeMismatchError",
76
+ "StagedMetadataError",
77
+ "StorageError",
78
+ "WriteIntentRangeError",
79
+ ]
@@ -0,0 +1,152 @@
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
+ """Shared binning primitives for the gridding extensions.
16
+
17
+ A *binner* maps irregular ``(lat, lon)`` input samples onto a 1-D **target
18
+ axis** of cells, recording for each usable input point the integer position of
19
+ its target cell. Aggregation then scatters values onto that axis. This pattern
20
+ is grid-agnostic: a regular lat/lon grid (:mod:`.grid`) and a HEALPix grid
21
+ (:mod:`.healpix`) differ only in how the axis and the point->position mapping
22
+ are computed, not in how values are aggregated.
23
+
24
+ The target axis can be **derived from the data** or **supplied explicitly** by
25
+ the caller. Supplying it explicitly is what lets a plugin pin one fixed axis and
26
+ reuse it across many granules/timesteps (so results share a coordinate and can
27
+ be appended), rather than re-deriving a different axis from each input.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import numpy as np
33
+
34
+ AGGREGATIONS = ("mean", "min", "max", "first", "last", "median", "any")
35
+
36
+
37
+ def as_float_array(value: np.ndarray) -> np.ndarray:
38
+ arr = np.asarray(value)
39
+ if arr.dtype.kind == "f":
40
+ return arr
41
+ return arr.astype(np.float64, copy=False)
42
+
43
+
44
+ def broadcast_latlon(
45
+ lat: np.ndarray, lon: np.ndarray, data: np.ndarray
46
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
47
+ """Normalize lat/lon/data shapes to 1D flattened arrays of equal length."""
48
+ lat_arr = np.asarray(lat)
49
+ lon_arr = np.asarray(lon)
50
+ data_arr = np.asarray(data)
51
+
52
+ if lat_arr.ndim == 1 and lon_arr.ndim == 1 and data_arr.ndim == 2:
53
+ # Rectilinear case: data[lat, lon] with 1D coords.
54
+ lon2, lat2 = np.meshgrid(lon_arr, lat_arr)
55
+ return lat2.ravel(), lon2.ravel(), data_arr.ravel()
56
+
57
+ if lat_arr.shape != lon_arr.shape or lat_arr.shape != data_arr.shape:
58
+ raise ValueError(
59
+ "lat, lon, and data must have matching shapes (or lat/lon 1D with data 2D). "
60
+ f"Got lat={lat_arr.shape}, lon={lon_arr.shape}, data={data_arr.shape}."
61
+ )
62
+ return lat_arr.ravel(), lon_arr.ravel(), data_arr.ravel()
63
+
64
+
65
+ def aggregate_by_position(
66
+ *,
67
+ values_flat: np.ndarray,
68
+ valid_point_index: np.ndarray,
69
+ position: np.ndarray,
70
+ n_targets: int,
71
+ aggregation: str,
72
+ ) -> np.ndarray:
73
+ """Scatter flattened values onto a 1-D target axis of length ``n_targets``.
74
+
75
+ Parameters
76
+ ----------
77
+ values_flat:
78
+ Flattened input values (same flattening as the binner was built from).
79
+ valid_point_index:
80
+ Indices into ``values_flat`` for the points the binner kept.
81
+ position:
82
+ For each kept point, the target-axis index in ``[0, n_targets)``.
83
+ n_targets:
84
+ Length of the output axis.
85
+ aggregation:
86
+ One of :data:`AGGREGATIONS`. ``any`` returns a ``uint8`` presence mask;
87
+ all others return ``float32`` with ``NaN`` in empty cells.
88
+ """
89
+ aggregation = str(aggregation).lower()
90
+ if aggregation not in AGGREGATIONS:
91
+ raise ValueError(
92
+ f"Unknown aggregation method: {aggregation}. Options: {', '.join(AGGREGATIONS)}"
93
+ )
94
+
95
+ values_flat = as_float_array(values_flat).ravel()
96
+ values = values_flat[valid_point_index]
97
+
98
+ ok = ~np.isnan(values)
99
+ if ok.sum() == 0:
100
+ if aggregation == "any":
101
+ return np.zeros(n_targets, dtype=np.uint8)
102
+ return np.full(n_targets, np.nan, dtype=np.float32)
103
+
104
+ pos = position[ok]
105
+ vals = values[ok]
106
+
107
+ if aggregation == "any":
108
+ out = np.zeros(n_targets, dtype=np.uint8)
109
+ np.logical_or.at(out, pos, vals != 0)
110
+ return out
111
+
112
+ if aggregation == "mean":
113
+ sums = np.bincount(pos, weights=vals, minlength=n_targets).astype(np.float64, copy=False)
114
+ counts = np.bincount(pos, minlength=n_targets).astype(np.float64, copy=False)
115
+ out = np.full(n_targets, np.nan, dtype=np.float32)
116
+ nonzero = counts > 0
117
+ out[nonzero] = (sums[nonzero] / counts[nonzero]).astype(np.float32)
118
+ return out
119
+
120
+ if aggregation == "min":
121
+ out = np.full(n_targets, np.inf, dtype=np.float64)
122
+ np.minimum.at(out, pos, vals)
123
+ out[~np.isfinite(out)] = np.nan
124
+ return out.astype(np.float32)
125
+
126
+ if aggregation == "max":
127
+ out = np.full(n_targets, -np.inf, dtype=np.float64)
128
+ np.maximum.at(out, pos, vals)
129
+ out[~np.isfinite(out)] = np.nan
130
+ return out.astype(np.float32)
131
+
132
+ if aggregation in {"first", "last"}:
133
+ order = np.argsort(pos, kind="stable")
134
+ pos_sorted = pos[order]
135
+ vals_sorted = vals[order]
136
+ if aggregation == "last":
137
+ pos_sorted = pos_sorted[::-1]
138
+ vals_sorted = vals_sorted[::-1]
139
+ _, first_pos = np.unique(pos_sorted, return_index=True)
140
+ out = np.full(n_targets, np.nan, dtype=np.float32)
141
+ out[pos_sorted[first_pos]] = vals_sorted[first_pos].astype(np.float32, copy=False)
142
+ return out
143
+
144
+ # median
145
+ order = np.argsort(pos, kind="stable")
146
+ pos_sorted = pos[order]
147
+ vals_sorted = vals[order]
148
+ unique_pos, start_idx, counts = np.unique(pos_sorted, return_index=True, return_counts=True)
149
+ out = np.full(n_targets, np.nan, dtype=np.float32)
150
+ for cell, start, count in zip(unique_pos, start_idx, counts, strict=False):
151
+ out[int(cell)] = np.median(vals_sorted[start : start + count]).astype(np.float32)
152
+ return out
@@ -0,0 +1,153 @@
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
+ """DuckDB extension for Firecube Ingestors.
16
+
17
+ Provides DuckDbMixin for managing DuckDB connection lifecycle.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import threading
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from firecube.ingestor.types.context import PluginContext
28
+ from firecube.ingestor.utils import duckdb_utils
29
+ from firecube.ingestor.utils.duckdb_utils import ensure_table
30
+
31
+ __all__ = ["DuckDbMixin", "ensure_table"]
32
+
33
+ if TYPE_CHECKING:
34
+ import duckdb
35
+
36
+
37
+ class DuckDbMixin:
38
+ """Mixin that manages a thread-local DuckDB connection for batch processing.
39
+
40
+ Why thread-local?
41
+ In parallel pipeline mode, each worker thread calls ``batch_setup`` /
42
+ ``batch_teardown`` independently. DuckDB connections are **not**
43
+ thread-safe for concurrent writes; giving each thread its own in-memory
44
+ connection avoids locking and eliminates the risk of file-level write
45
+ conflicts when using a persistent ``*.duckdb`` file.
46
+
47
+ Lifecycle per batch (driven by ``BaseIngestor``):
48
+ ``batch_setup(ctx)`` → opens connection, applies settings, calls
49
+ ``prepare_duckdb_schema`` hook.
50
+ ``batch_teardown(ctx)`` → closes and deletes the thread-local connection.
51
+
52
+ MRO cooperation:
53
+ Both ``batch_setup`` and ``batch_teardown`` call ``super()``
54
+ cooperatively so that this mixin can be stacked with other mixins
55
+ (e.g. ``ProductDataMixin``) without silently dropping their setup logic.
56
+
57
+ Persistent mode (``duckdb_persist_batches=true``):
58
+ Workers use an in-memory DB by default. Persistence can be enabled
59
+ for debugging or when accumulating rows across batches before a single
60
+ final export. Persistent connections use a file under the run's temp
61
+ workspace; the main thread pre-initialises the schema in
62
+ ``GenericZarrIngestor.on_pipeline_start`` to avoid races on first write.
63
+ """
64
+
65
+ @property
66
+ def _db_local(self) -> threading.local:
67
+ # Lazy-init the threading.local storage so that subclasses with
68
+ # __slots__ or dataclass-style __init__ don't need to call super().__init__().
69
+ if not hasattr(self, "_db_local_storage"):
70
+ self._db_local_storage = threading.local()
71
+ return self._db_local_storage
72
+
73
+ @property
74
+ def con(self) -> duckdb.DuckDBPyConnection:
75
+ if not hasattr(self._db_local, "con"):
76
+ raise RuntimeError("DuckDB connection not initialized for this thread")
77
+ return self._db_local.con
78
+
79
+ @con.setter
80
+ def con(self, value: duckdb.DuckDBPyConnection) -> None:
81
+ self._db_local.con = value
82
+
83
+ def setup_duckdb(
84
+ self,
85
+ workspace: Path | None = None,
86
+ options: dict[str, Any] | None = None,
87
+ in_memory: bool = True,
88
+ ) -> None:
89
+ """Initialize DuckDB connection with settings (THREAD-LOCAL)."""
90
+ db_path = None
91
+ if not in_memory and workspace:
92
+ product_name = getattr(self, "PRODUCT_NAME", "product")
93
+ filename = (options or {}).get("duckdb_filename", f"{product_name}.duckdb")
94
+ db_path = workspace / filename
95
+
96
+ self.con = duckdb_utils.open_duckdb(in_memory=in_memory, db_path=db_path)
97
+
98
+ if options:
99
+ duckdb_utils.apply_duckdb_settings(
100
+ self.con,
101
+ max_temp_directory_size=options.get("duckdb_max_temp_directory_size"),
102
+ memory_limit=options.get("duckdb_memory_limit"),
103
+ threads=options.get("duckdb_threads"),
104
+ logger=getattr(self, "_log", None),
105
+ )
106
+
107
+ def teardown_duckdb(self) -> None:
108
+ """Close DuckDB connection (THREAD-LOCAL)."""
109
+ if hasattr(self._db_local, "con"):
110
+ with contextlib.suppress(Exception):
111
+ self.con.close()
112
+ del self._db_local.con
113
+
114
+ def batch_setup(self, ctx: PluginContext) -> None:
115
+ # Memory vs persist decision:
116
+ # ctx.in_memory=True → always use in-memory (test mode or explicit flag).
117
+ # ctx.in_memory=False → default to in-memory unless duckdb_persist_batches=true,
118
+ # which writes a per-worker file under the run's temp workspace.
119
+ persist = ctx.option("duckdb_persist_batches", False)
120
+ use_memory = True if ctx.in_memory else (not persist)
121
+ workspace = getattr(self, "_workspace", None)
122
+
123
+ self.setup_duckdb(
124
+ workspace=workspace.temp_root if workspace else None,
125
+ options=ctx.options,
126
+ in_memory=use_memory,
127
+ )
128
+
129
+ # Ensure schema exists (whether fresh in-memory or existing persistent)
130
+ try:
131
+ self.prepare_duckdb_schema(self.con, ctx)
132
+ except Exception as e:
133
+ logger = getattr(self, "_log", None)
134
+ if logger:
135
+ logger.warning("Failed to prepare batch DuckDB schema: %s", e)
136
+ raise
137
+
138
+ # Cooperative MI
139
+ # Optional by design: if no parent hook exists in the MRO, this is a no-op.
140
+ super_setup = getattr(super(), "batch_setup", None)
141
+ if callable(super_setup):
142
+ super_setup(ctx)
143
+
144
+ def batch_teardown(self, ctx: PluginContext) -> None:
145
+ self.teardown_duckdb()
146
+ # Optional by design: if no parent hook exists in the MRO, this is a no-op.
147
+ super_teardown = getattr(super(), "batch_teardown", None)
148
+ if callable(super_teardown):
149
+ super_teardown(ctx)
150
+
151
+ def prepare_duckdb_schema(self, con: duckdb.DuckDBPyConnection, ctx: PluginContext) -> None:
152
+ """Optional hook to initialize tables in persistent DB mode."""
153
+ pass