-
-
Notifications
You must be signed in to change notification settings - Fork 36
feat: add Arraylake source control #1889
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
ghostiee-11
wants to merge
2
commits into
holoviz:main
Choose a base branch
from
ghostiee-11:feat/arraylake-source-control
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import param | ||
|
|
||
| from ....sources.xarray_sql import XArraySQLSource | ||
| from ...translate import params_to_callable | ||
| from .parametric import ParametricSourceControls | ||
| from .result import SourceResult | ||
| from .utils import open_arraylake_dataset | ||
|
|
||
|
|
||
| class ArraylakeSourceControls(ParametricSourceControls): | ||
| """ | ||
| Parametric controls that open an Arraylake repository as a | ||
| SQL-queryable xarray source. | ||
|
|
||
| The user supplies a repo, branch, and optional Zarr group; the control | ||
| opens the Icechunk-backed store with xarray and registers the result as | ||
| an ``XArraySQLSource`` (one SQL table per gridded data variable), mirroring | ||
| how uploaded CSVs become a ``DuckDBSource``. | ||
|
|
||
| Example | ||
| ------- | ||
| :: | ||
|
|
||
| from lumen.ai.controls.ingest import ArraylakeSourceControls | ||
| ui = ExplorerUI(source_controls=[ArraylakeSourceControls]) | ||
|
|
||
| Requires the optional dependencies (Python >=3.12):: | ||
|
|
||
| pip install lumen[arraylake] | ||
| """ | ||
|
|
||
| repo = param.String(default="", doc=""" | ||
| Arraylake repository, e.g. 'earthmover-public/goes-16'.""") | ||
|
|
||
| branch = param.String(default="main", doc=""" | ||
| Branch or ref to read from.""") | ||
|
|
||
| group = param.String(default="", doc=""" | ||
| Optional Zarr group within the repository.""") | ||
|
|
||
| variables = param.List(default=None, allow_None=True, precedence=-1, doc=""" | ||
| Subset of data variables to expose as tables. When omitted, all | ||
| gridded (non-scalar) variables are exposed.""") | ||
|
|
||
| label = '<span class="material-icons" style="vertical-align: middle;">cloud_queue</span> Arraylake' | ||
|
|
||
| def as_tools( | ||
| self, query: str | None = None, top_k: int = 5, | ||
| ) -> list[tuple[str, callable]]: | ||
| """Expose the repo/branch/group params as a single typed agent tool. | ||
|
|
||
| Mirrors ``URLSourceControls.as_tools``: subclass-pattern controls | ||
| declare class-level params rather than registering actions, so we | ||
| synthesize one callable whose signature mirrors those params for | ||
| ``FunctionTool`` to build a schema from. | ||
| """ | ||
| if self._cached_tools is not None: | ||
| return self._cached_tools | ||
|
|
||
| query_names = self._get_query_param_names() | ||
| if not query_names: | ||
| self._cached_tools = [] | ||
| return self._cached_tools | ||
|
|
||
| action_name = self.__class__.__name__.removesuffix("Controls") | ||
|
|
||
| async def _tool_callable(**kwargs) -> SourceResult: | ||
| return await self.load_action(action_name, **kwargs) | ||
|
|
||
| query_params = {name: self.param[name] for name in query_names} | ||
| doc = (self.__doc__ or f"Load data from {action_name}.").strip() | ||
| params_to_callable( | ||
| _tool_callable, query_params, | ||
| name=action_name.lower() or "load_data", | ||
| doc=doc, | ||
| ) | ||
|
|
||
| self._cached_tools = [(action_name, _tool_callable)] | ||
| return self._cached_tools | ||
|
|
||
| async def _fetch_data(self, action_name: str, **params) -> SourceResult: | ||
| repo = (params.get("repo") or self.repo).strip() | ||
| if not repo: | ||
| return SourceResult.empty( | ||
| "Provide an Arraylake repo, e.g. 'earthmover-public/goes-16'." | ||
| ) | ||
| branch = (params.get("branch") or self.branch).strip() or "main" | ||
| group = (params.get("group") or self.group).strip() or None | ||
|
|
||
| self.progress(f"Opening Arraylake repo {repo!r}") | ||
| try: | ||
| ds = open_arraylake_dataset(repo, branch=branch, group=group) | ||
| except ImportError: | ||
| return SourceResult.empty( | ||
| "Arraylake support requires `pip install lumen[arraylake]` " | ||
| "(Python >=3.12)." | ||
| ) | ||
| except Exception as e: | ||
| return SourceResult.empty(f"Could not open Arraylake repo {repo!r}: {e}") | ||
|
|
||
| # Drop 0-dim scalar metadata variables: they have no chunkable | ||
| # dimension and cannot become a partitioned table. | ||
| variables = self.variables or [ | ||
| name for name, var in ds.data_vars.items() if var.ndim > 0 | ||
| ] | ||
| if not variables: | ||
| return SourceResult.empty( | ||
| f"No gridded data variables to load from {repo!r}." | ||
| ) | ||
|
|
||
| source_id = f"{self.source_name_prefix}{self._count:06d}" | ||
| self._count += 1 | ||
| try: | ||
| # Virtual-zarr stores (e.g. GOES) can chunk variables | ||
| # inconsistently along a shared dimension; unify so the dataset | ||
| # registers as a single coherent set of tables. | ||
| ds = ds.unify_chunks() | ||
| source = XArraySQLSource.from_dataset( | ||
| ds, variables=variables, name=source_id | ||
| ) | ||
| except Exception as e: | ||
| return SourceResult.empty( | ||
| f"Could not register {repo!r} as a source: {e}" | ||
| ) | ||
|
|
||
| tables = source.get_tables() | ||
| first_table = tables[0] if tables else None | ||
| location = "/".join(part for part in (repo, branch, group) if part) | ||
| message = f"Loaded {len(tables)} tables from Arraylake {location}" | ||
| return SourceResult.from_source(source, table=first_table, message=message) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import inspect | ||
| import sys | ||
| import types | ||
|
|
||
| from unittest.mock import MagicMock | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
|
|
||
| try: | ||
| import xarray as xr | ||
| import xarray_sql # noqa | ||
| except ModuleNotFoundError: | ||
| pytest.skip( | ||
| "xarray / xarray-sql not installed, skipping Arraylake control tests.", | ||
| allow_module_level=True, | ||
| ) | ||
|
|
||
| from lumen.ai.controls import ArraylakeSourceControls | ||
| from lumen.ai.controls.ingest.result import SourceResult | ||
| from lumen.sources.xarray_sql import XArraySQLSource | ||
|
|
||
|
|
||
| def _goes_like(): | ||
| """A GOES-like dataset: (y, x) bands plus a 0-dim scalar metadata var.""" | ||
| return xr.Dataset( | ||
| { | ||
| "CMI_C13": (("y", "x"), np.random.rand(4, 5)), | ||
| "DQF_C13": (("y", "x"), np.random.randint(0, 4, (4, 5))), | ||
| "goes_imager_projection": ((), 0), | ||
| }, | ||
| coords={"y": np.arange(4), "x": np.arange(5)}, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def arraylake_controls(context, source_catalog): | ||
| return ArraylakeSourceControls(context=context, source_catalog=source_catalog) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def fake_arraylake(monkeypatch): | ||
| """Inject a stub ``arraylake`` module so the lazy import succeeds.""" | ||
| module = types.ModuleType("arraylake") | ||
| module.Client = MagicMock() | ||
| monkeypatch.setitem(sys.modules, "arraylake", module) | ||
| return module | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| class TestArraylakeSourceControls: | ||
|
|
||
| async def test_fetch_data_builds_xarray_source( | ||
| self, arraylake_controls, fake_arraylake, monkeypatch | ||
| ): | ||
| monkeypatch.setattr(xr, "open_zarr", lambda *a, **k: _goes_like()) | ||
| result = await arraylake_controls._fetch_data( | ||
| "ArraylakeSource", | ||
| repo="earthmover-public/goes-16", | ||
| branch="main", | ||
| group="", | ||
| ) | ||
| assert isinstance(result, SourceResult) | ||
| assert len(result.sources) == 1 | ||
| source = result.sources[0] | ||
| assert isinstance(source, XArraySQLSource) | ||
| assert set(source.get_tables()) == {"CMI_C13", "DQF_C13"} | ||
| assert result.table == "CMI_C13" | ||
|
|
||
| async def test_scalar_variable_dropped( | ||
| self, arraylake_controls, fake_arraylake, monkeypatch | ||
| ): | ||
| monkeypatch.setattr(xr, "open_zarr", lambda *a, **k: _goes_like()) | ||
| result = await arraylake_controls._fetch_data("ArraylakeSource", repo="o/r") | ||
| assert "goes_imager_projection" not in result.sources[0].get_tables() | ||
|
|
||
| async def test_explicit_variables_override( | ||
| self, arraylake_controls, fake_arraylake, monkeypatch | ||
| ): | ||
| monkeypatch.setattr(xr, "open_zarr", lambda *a, **k: _goes_like()) | ||
| arraylake_controls.variables = ["CMI_C13"] | ||
| result = await arraylake_controls._fetch_data("ArraylakeSource", repo="o/r") | ||
| assert result.sources[0].get_tables() == ["CMI_C13"] | ||
|
|
||
| async def test_missing_repo_returns_empty(self, arraylake_controls): | ||
| result = await arraylake_controls._fetch_data("ArraylakeSource", repo="") | ||
| assert result.sources == [] | ||
| assert "repo" in result.message.lower() | ||
|
|
||
| async def test_missing_arraylake_returns_empty( | ||
| self, arraylake_controls, monkeypatch | ||
| ): | ||
| # Setting the module to None makes ``import arraylake`` raise ImportError. | ||
| monkeypatch.setitem(sys.modules, "arraylake", None) | ||
| result = await arraylake_controls._fetch_data("ArraylakeSource", repo="o/r") | ||
| assert result.sources == [] | ||
| assert "lumen[arraylake]" in result.message | ||
|
|
||
| async def test_open_error_returns_empty( | ||
| self, arraylake_controls, fake_arraylake, monkeypatch | ||
| ): | ||
| def boom(*a, **k): | ||
| raise RuntimeError("connection refused") | ||
|
|
||
| monkeypatch.setattr(xr, "open_zarr", boom) | ||
| result = await arraylake_controls._fetch_data("ArraylakeSource", repo="o/r") | ||
| assert result.sources == [] | ||
| assert "connection refused" in result.message | ||
|
|
||
|
|
||
| def test_as_tools_exposes_typed_action(arraylake_controls): | ||
| tools = arraylake_controls.as_tools() | ||
| assert len(tools) == 1 | ||
| name, func = tools[0] | ||
| assert name == "ArraylakeSource" | ||
| assert list(inspect.signature(func).parameters) == ["repo", "branch", "group"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe use as util?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, will add this in util...