Skip to content

Support plotting xarray grids (hvPlot + VegaLite + DeckGL)#1823

Open
ghostiee-11 wants to merge 27 commits into
holoviz:mainfrom
ghostiee-11:feat/xarray-gridded-hvplot
Open

Support plotting xarray grids (hvPlot + VegaLite + DeckGL)#1823
ghostiee-11 wants to merge 27 commits into
holoviz:mainfrom
ghostiee-11:feat/xarray-gridded-hvplot

Conversation

@ghostiee-11

@ghostiee-11 ghostiee-11 commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

Asking Lumen AI to plot a 2D xarray field (like air temperature over lon/lat) used to fall back to scatter or an empty chart, because the value column had nowhere to go and the agents had no signal the data came from an xarray source. This adds gridded support across hvPlot (quadmesh/image/heatmap), VegaLite (rect heatmap), and DeckGL, so the AI now picks the right 2D encoding automatically. Supersedes #1824 and #1825.

After

Screen.Recording.2026-07-04.at.7.55.18.PM.mov

Closes #1821.

After holoviz#1791 landed, loading a NetCDF file and asking the AI to plot it
produces a scatter or line chart rather than a 2D heatmap, because:

- hvPlotBaseView had no way to pass a value column (C) to hvplot
- The AI agents had no awareness the pipeline was backed by gridded
  xarray data

Changes:
- Add `C` param (value column) to hvPlotBaseView; wire into df.hvplot()
- Add `quadmesh` to the kind selector
- New lumen/ai/gridded.py: detect_gridded() returns coordinate/variable
  metadata when pipeline.source is XArraySQLSource, else None
- BaseViewAgent._generate_yaml_spec: call detect_gridded() once, pass
  result as `gridded` template variable
- BaseViewAgent/main.jinja2 + hvPlotAgent/main.jinja2: conditional
  block guides LLM to use kind='heatmap' with x/y/C for gridded data
- hvPlotAgent._extract_spec: drop by/groupby when kind=heatmap

Addresses holoviz#1821 (1/3)
@codecov

codecov Bot commented Apr 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.71127% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.79%. Comparing base (eaf1c6d) to head (81940f0).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
lumen/ai/utils.py 83.33% 4 Missing ⚠️
lumen/ai/agents/deck_gl.py 0.00% 3 Missing ⚠️
lumen/views/base.py 96.25% 3 Missing ⚠️
lumen/ai/agents/base_view.py 0.00% 1 Missing ⚠️
lumen/ai/agents/vega_lite.py 66.66% 1 Missing ⚠️
lumen/pipeline.py 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1823      +/-   ##
==========================================
+ Coverage   71.22%   71.79%   +0.57%     
==========================================
  Files         198      202       +4     
  Lines       34163    34844     +681     
==========================================
+ Hits        24332    25017     +685     
+ Misses       9831     9827       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Per review on holoviz#1824: hvPlot uses `C` only for heatmap (matplotlib
hexbin convention), and `z` for image, quadmesh, contourf. Exposing
the param as `C` for all gridded kinds would silently pass C= to plot
kinds that expect z=.

Fix: rename the public param `C` to `z` and map internally to C= only
when kind='heatmap', otherwise pass through as z=. Updated prompt
guidance in BaseViewAgent/main.jinja2 and hvPlotAgent/main.jinja2 to
use z= and mention the other gridded kinds. Tests updated to use z=
and a new test verifies the z-to-C mapping is conditional on kind.
@ghostiee-11 ghostiee-11 changed the title Add C param and heatmap support for xarray-derived pipelines Add z param and heatmap support for xarray-derived pipelines Apr 21, 2026
@ahuang11

Copy link
Copy Markdown
Contributor

I suppose heatmap isn't the right type for plotting gridded data; the better one is hvplot.image or hvplot.quadmesh, and I wonder if there's a way to detect if isinstance(XArraySQLSource) or something.

Per review on holoviz#1823: heatmap treats x/y as ordinal categories, which is
semantically wrong for continuous-coordinate gridded data. quadmesh and
image preserve the continuous axes and are the right default for xarray-
derived grids. But hvPlot's quadmesh/image/contourf kinds raise
NotImplementedError on long-form pandas DataFrames (they expect a 2D
array or xarray), and XArraySQLSource returns pandas via SQL.

Fix: hvPlotBaseView.get_plot now pivots the long-form DataFrame to a 2D
xarray DataArray (via set_index([y, x])[z].to_xarray()) when kind is in
_GRIDDED_KINDS = (quadmesh, image, contourf) and x/y/z are set.

Safety guards in _to_gridded:
- Skips pivot if the input is not a pandas DataFrame (xarray objects
  pass through unchanged, so future xarray-returning sources work).
- Skips pivot if hvplot.xarray failed to import (import flag set at
  __init__ time; avoids AttributeError at plot time).
- Skips pivot if x/y/z are missing or required columns are absent
  (falls back to hvPlot's own NotImplementedError).
- Skips pivot if duplicate (y, x) pairs exist (pivot would be ambiguous).
- Raises ValueError if the pivoted grid would exceed
  _GRIDDED_MAX_CELLS = 10M cells (~80MB for float64) to prevent OOM.

Prompt guidance updated: recommends kind='quadmesh' as the primary
choice for xarray-derived gridded data, with image/contourf as
alternatives and heatmap as a fallback for ordinal/categorical axes.
heatmap still stays on the pandas path (it works fine there).

hvplot.xarray is imported in hvPlotBaseView.__init__ alongside
hvplot.pandas so the .hvplot accessor is available on DataArray.

Tests (10 in test_hvplot_gridded.py):
- test_hvplot_quadmesh_from_longform_df: renders hv.QuadMesh
- test_hvplot_image_from_longform_df: renders hv.Image
- test_hvplot_gridded_size_guard_raises: ValueError over cap
- test_hvplot_gridded_skips_pivot_if_xarray_unavailable: flag fallback
- test_hvplot_gridded_passes_through_non_dataframe: xarray input skip
- existing z-param and heatmap tests updated
@ghostiee-11

ghostiee-11 commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Good call, thanks. Pushed f6e188f0 that swings the default toward quadmesh and image.

What changed:

  1. hvPlotBaseView.get_plot now pivots long-form pandas to a 2D xarray DataArray (via set_index([y, x])[z].to_xarray()) when kind is in (quadmesh, image, contourf). Without this, hvplot raises NotImplementedError on pandas for those kinds since XArraySQLSource returns pandas via SQL. Verified by test_hvplot_quadmesh_from_longform_df and test_hvplot_image_from_longform_df, both render real hv.QuadMesh and hv.Image instances.

  2. Prompt guidance now recommends kind='quadmesh' as the primary choice for xarray-derived data, with image/contourf as alternatives and heatmap as a fallback for ordinal/categorical axes.

  3. The isinstance(XArraySQLSource) detection already exists as detect_gridded() in lumen/ai/gridded.py (from the original commit on this PR). It drives the BaseViewAgent prompt's gridded block, so the LLM only gets quadmesh-preferring instructions when the pipeline is actually xarray-backed.

Scope choice to flag: the pivot itself is source-agnostic, it fires whenever kind is quadmesh/image/contourf and x/y/z columns are present and unambiguous. That way a plain CSV with lat/lon/value columns also works if the user explicitly picks quadmesh. If you'd prefer to restrict the pivot to isinstance(pipeline.source, XArraySQLSource) only, happy to tighten it.

Safety guards in _to_gridded:

  • xarray passthrough when the input is already a DataArray/Dataset
  • _hvplot_xarray_available flag set at init, skips pivot if the xarray accessor is unavailable
  • _GRIDDED_MAX_CELLS = 10M cap on pivoted grid size, raises ValueError with a clear message rather than risking OOM
  • pivot skipped if duplicate (y, x) pairs make it ambiguous

43 tests pass in lumen/tests/views/ and lumen/tests/ai/test_gridded.py.

Pre-commit ruff auto-fix flagged the # noqa as unused; the try/except
already silences the ImportError, so the # type: ignore is enough.
The previous lint cleanup over-stripped the noqa on hvplot.dask, which
is a genuine F401 (conditional side-effect import for the .hvplot
accessor on dask DataFrames). Restored as scoped # noqa: F401 so
RUF100 recognises it as a targeted suppression.
@ghostiee-11
ghostiee-11 marked this pull request as draft April 23, 2026 19:30
Two review responses bundled:

1. Per review on holoviz#1824: BaseViewAgent/main.jinja2 is the base template
   inherited by hvPlotAgent, VegaLiteAgent, and DeckGLAgent. The previous
   gridded block named kind='quadmesh'/'image'/'contourf'/'heatmap' which
   are hvPlot-only concepts. VegaLite uses mark: rect, DeckGL uses layer
   types, so injecting hvPlot kind values into their prompt context is
   misleading. Kept the metadata (xarray source, dims, data_vars) in the
   base block; each agent's own prompt carries the syntax-specific
   guidance. Updated test to assert no hvPlot kinds leak into the base.

2. Style cleanup for lint discipline: moved MagicMock/patch/holoviews/
   numpy/pandas imports to the top of test_hvplot_gridded.py (was inline
   inside test bodies). xarray is an optional dep, so it is guarded with
   pytest.importorskip inside the tests that actually use it to keep
   test-core collection working. Replaced two em dashes in hvPlotAgent
   prompt and a test docstring.
@ghostiee-11
ghostiee-11 force-pushed the feat/xarray-gridded-hvplot branch from cc91fec to 43e7574 Compare April 23, 2026 19:52
@ghostiee-11
ghostiee-11 marked this pull request as ready for review April 23, 2026 20:02
Comment thread lumen/ai/gridded.py Outdated
Per review on holoviz#1823: detect_gridded is a small one-function helper;
it does not warrant its own module. Moved into lumen/ai/utils.py
alongside the other AI helpers and removed the standalone gridded.py.

Updated imports in lumen/ai/agents/base_view.py and
lumen/tests/ai/test_gridded.py.
Comment thread lumen/views/base.py Outdated
Comment thread lumen/views/base.py Outdated
Comment thread lumen/ai/utils.py Outdated
Comment thread lumen/ai/prompts/hvPlotAgent/main.jinja2 Outdated
Comment thread lumen/ai/utils.py Outdated
Comment thread lumen/views/base.py Outdated
Comment thread lumen/views/base.py Outdated
@ahuang11

Copy link
Copy Markdown
Contributor

I'm confused about your screenshots:

image

Are those both showing the before? If it's after, why doesn't it match
image

@ahuang11
ahuang11 marked this pull request as draft April 30, 2026 18:27
Polish-and-clarity items from the 2026-04-30 review pass:

* views/base.py: replace per-instance ``type(self)._hvplot_xarray_available``
  with a single module-level ``_HVPLOT_XARRAY_AVAILABLE`` constant probed
  once at import; hoist ``import pandas as pd`` to the top; consolidate
  the early-return guards in ``_to_gridded``; extract ``_gridded_pivot_blocker``
  so a gridded ``kind`` with an unpivotable frame surfaces a clear
  ``ValueError`` instead of hvPlot's opaque ``NotImplementedError``.
* ai/utils.py: rename ``detect_gridded`` to ``gridded_metadata`` (the
  return type is a dict not a bool); collapse the two early-return guards
  into one; add a ``regular`` flag computed from coord uniformity (with
  a datetime64-safe path) so the LLM has a concrete signal for the
  image-vs-quadmesh choice.
* prompts/hvPlotAgent: deterministic kind selection driven by
  ``gridded.regular``: regular grids prefer ``kind='image'``, irregular
  grids prefer ``kind='quadmesh'`` and are explicitly told not to use
  ``image``.
* tests: two new failure-signal tests (duplicate-rows and missing-z),
  ``regular`` coverage for regular / irregular / datetime coords, and
  branch tests for the prompt's image vs quadmesh wording.
* views/base.py: merge the two early returns in `_to_gridded` into a
  single `if not isinstance(...) or _gridded_pivot_blocker(...)`,
  consistent with the consolidation pattern asked for in the earlier
  review pass.
* tests: add `test_hvplot_gridded_xarray_dataset_renders` which feeds
  an xarray Dataset straight into `get_plot` and asserts the result
  is a real `hv.QuadMesh`. Standard Lumen sources only emit
  DataFrames, so the test bypasses the pipeline and passes the
  Dataset directly to the view.
@ghostiee-11

ghostiee-11 commented May 7, 2026

Copy link
Copy Markdown
Collaborator Author

Sorry for the confusion on those, you're right that they didn't actually demonstrate the fix. The original demo used a tiny 4x5 synthetic grid which made the after look mostly uniform and almost identical to the before.

I've redone the demo with a real dataset (xr.tutorial.air_temperature, 25 lat by 53 lon) and pulled the comparison back to a single line of view code so the difference is unambiguous:

Before (main): the same view spec raises a clear ValueError because 'quadmesh' isn't in the allowed kinds and there's no z= param to route the value column.

programmatic-before-main

After (this branch): the view pivots the long-form pandas frame to a 2D xarray DataArray and renders the quadmesh with the proper color scale, matching what ds.hvplot.image("lon", "lat", "air") produces directly.

programmatic-after-feat
view = hvPlotView(pipeline=pipe, kind="quadmesh", x="lon", y="lat", z="air")
view.get_panel()

@ghostiee-11

Copy link
Copy Markdown
Collaborator Author

Pushed d99b530 which addresses the two follow-ups from your last pass:

  1. Collapsed the two early-return guards in _to_gridded into a single if not isinstance(df, pd.DataFrame) or self._gridded_pivot_blocker(df) is not None: return df, matching the consolidation style you flagged.
  2. Added test_hvplot_gridded_xarray_dataset_renders to confirm the non-DataFrame pass-through actually produces a hv.QuadMesh downstream, not just that the object is returned unchanged. Standard Lumen sources only emit DataFrames, so the test feeds the Dataset directly to get_plot to exercise the path.

@ghostiee-11
ghostiee-11 marked this pull request as ready for review May 7, 2026 08:34
Wire gridded metadata into VegaLiteAgent's YAML and Altair spec
generators, and add mark:rect heatmap guidance plus an example. xarray
derived grids now render as viridis heatmaps instead of empty geographic
charts. The agent overrides the base generators, so it computes the
gridded metadata itself rather than inheriting it.
Wire gridded metadata into DeckGLAgent's declarative and PyDeck spec
generators and add ColumnLayer/HexagonLayer guidance. Guard DeckGLView
against frames larger than 250k rows, which reliably exhaust browser
memory, raising a clear ValueError instead.
@ghostiee-11 ghostiee-11 changed the title Add z param and heatmap support for xarray-derived pipelines Support plotting xarray grids (hvPlot + VegaLite + DeckGL) Jul 4, 2026
Resolve conflicts in lumen/ai/utils.py and lumen/ai/agents/vega_lite.py:
keep both gridded_metadata and the new normalize_vegalite_spec, and switch
gridded_metadata + its test from the removed check_xarray_available to the
new try_import_xarray helper.
groupby is valid for an hvplot heatmap (it creates a widget selector), so
the agent should not strip it. Removed the heatmap by/groupby drop; the
gridded prompt guidance already covers appropriate field usage.
- Move the grid-cell cap and DeckGL row cap to module-level constants
  (GRIDDED_MAX_CELLS, DECKGL_MAX_ROWS) instead of ALL_CAPS class attributes.
- Source the gridded plot kinds from hvplot's HoloViewsConverter._gridded_types
  via _gridded_kinds() rather than hardcoding the tuple.
- Detect xarray with the try_import_xarray helper and import hvplot.xarray
  lazily where the accessor is used, dropping the module-level try/except flag.
@ghostiee-11
ghostiee-11 marked this pull request as ready for review July 8, 2026 22:04
The lazy import only ran on the pivot path, so handing an xarray object
straight through left the .hvplot accessor unregistered, which failed on a
clean import order (green on macOS/Windows, red on Ubuntu CI). Import it at
the top of _to_gridded so both the pivot and passthrough paths register it.
Importing hvplot.xarray pulls in xarray, so it must not run when xarray is
unavailable (the core test env). Return the blocked DataFrame before the
import; only register the accessor once xarray is confirmed present, i.e.
on the pivot or the xarray-passthrough path.
@ghostiee-11
ghostiee-11 requested a review from ahuang11 July 9, 2026 22:06
Comment thread lumen/views/base.py Outdated
limited to the 2D scalar-field kinds that map cleanly from x/y/z
columns (points/dataset/rgb are gridded to hvPlot but need no pivot).
"""
from hvplot.converter import HoloViewsConverter

@ahuang11 ahuang11 Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to have this nested under a function? I think we import hvplot elsewhere maybe?

Comment thread lumen/views/base.py Outdated
Comment on lines +1057 to +1060
return tuple(
kind for kind in HoloViewsConverter._gridded_types
if kind in ("contour", "contourf", "image", "quadmesh")
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this simply doing return ("contour", "contourf", "image", "quadmesh") but in a convoluted way?


## Gridded xarray data
`df` is a long-form grid from an xarray dataset (dimensions: {{ gridded.dims | join(', ') }}; variables: {{ gridded.data_vars | join(', ') }}).
Use `mark_rect()` for a heatmap: encode `x`/`y` as ordinal coordinate dimensions and `color` as the quantitative value with `alt.Scale(scheme='viridis')`. Do not use geographic projections.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no geographic projections?

@ahuang11

ahuang11 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Thanks! I think this is close. Can you run through xr.tutorial.open_dataset("air_temperature") example and show a video of it being plotted thru Lumen?

@ghostiee-11

Copy link
Copy Markdown
Collaborator Author

Sure..

- promote the gridded-kinds list to a module-level GRIDDED_KINDS constant,
  dropping the nested hvplot import and the convoluted comprehension
- allow groupby in the hvPlot prompt to page extra dimensions into a widget
  slider (by is still discouraged for a 2D scalar field)
- explain in the VegaLite prompt why mark_rect stays ordinal (Vega-Lite
  cannot project rect marks)
Fold any groupby columns into the pivot index so a grid with an extra
dimension (e.g. air_temperature's time axis) renders with a widget slider
instead of being rejected as duplicate (lat, lon) rows. The size guard and
duplicate check now span the full index. Matches the hvPlot prompt guidance
to use groupby for extra dimensions.
Non-reducing hvPlot kinds draw one glyph per row, so rendering a large
frame (a gridded xarray source expands to millions of long-form rows) can
exhaust the browser tab's memory and freeze it. hvPlotView and the AI's
hvPlotUIView now refuse to render past MAX_RENDER_ROWS with a clear message
pointing at a SQL LIMIT/aggregation, mirroring DeckGLView's existing cap.
Gridded/aggregating kinds and rasterize/datashade are exempt.
@ghostiee-11

Copy link
Copy Markdown
Collaborator Author

Ran it through air_temperature

That dataset is ~3.87M rows long-form. gridded kinds are fine (pivot to a small grid), but scatter/table would ship every row to the browser and freeze the tab. deck.gl already caps rows for this, so i added the same cap to hvPlotView/hvPlotUIView. now a per-row render past the limit raises a clear "reduce via LIMIT/aggregation" error instead of hanging. gridded/aggregating kinds + rasterize/datashade exempt.

Screen.Recording.2026-07-10.at.3.56.05.PM.mov

@ghostiee-11
ghostiee-11 requested a review from ahuang11 July 10, 2026 10:29
@ahuang11

ahuang11 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Wait, I thought we're using xql.to_dataset() already; if not, I think this PR should support that or else gridded datasets won't be usable in Lumen.

That dataset is ~3.87M rows long-form

Separately, does this include time?

@ghostiee-11

Copy link
Copy Markdown
Collaborator Author

Ohh yup, I forgot the xarray-sql thing, this pr was previous of that release will fix this on top of that

@ghostiee-11

Copy link
Copy Markdown
Collaborator Author

yeah it includes time: 2920 time × 25 lat × 53 lon = 3.87M. that's one row per (time, lat, lon) cell in long form, so kept gridded it's just a (2920, 25, 53) array.

@ahuang11

ahuang11 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

So besides hvplot, I don't think Vegalite and Deckgl can utilize timesteps? If accurate, we should instruct the agent to subset only 1 time slice (or more generally, keep it 2D unless colorby/groupby)

XArraySQLSource gains a to_dataset method that returns a gridded xr.Dataset
via xarray-sql's to_dataset (falling back to a long-form pivot on
xarray-sql < 0.3, and passing dims explicitly since multiple data variables
register as multiple tables). Pipeline.get_dataset exposes it for a plain
xarray-backed pipeline. hvPlotView renders gridded kinds straight from that
Dataset, and the AI explorer view (hvPlotUIView) now uses hvPlot's grid
explorer so image and quadmesh work instead of erroring on long-form pandas.
Bumps the xarray extra to xarray-sql>=0.3.1.
VegaLite specs and DeckGL layers are static and cannot page a dimension the
way hvPlot's groupby slider can, so a gridded source with an extra dim (e.g.
time) must be reduced to 2D. gridded_metadata now splits dims into spatial
(x/y) and extra dims, and subset_gridded_to_2d pins each extra dim to its
first value before the VegaLite/DeckGL agents render, deterministically (the
agents emit a spec over the pipeline and cannot reduce its rows via a prompt).
The prompts steer x/y to the spatial axes, and DeckGL getPosition now uses
those instead of dims[0]/dims[1], which mapped time onto a positional axis.
Apply isort import ordering to the gridded view imports flagged by the
pre-commit isort hook.
@ghostiee-11

Copy link
Copy Markdown
Collaborator Author
Screen.Recording.2026-07-11.at.2.22.22.AM.mp4

@ahuang11

Copy link
Copy Markdown
Contributor
image I wonder why the latitudes are flipped

gridded_metadata no longer labels which dims are spatial: a grid can be a
lon/lat map, a lon/time Hovmoller, or a lat/level section, so guessing lon/lat
by name is wrong. The DeckGL and VegaLite agents now generate the spec first,
then subset_gridded_to_2d reads the dims the spec actually references (Vega-Lite
encoding fields, deck.gl @@= accessors) and pins only the leftover dims to a
single slice. The model picks the axes, so a Hovmoller keeps time while an
unused time on a map still collapses. Also slims the gridded prompt blocks to
drop the lon/lat prescription and the per-dim filter instructions the code now
handles.
Comment thread lumen/ai/utils.py Outdated
or xarray is unavailable. Prompt guidance alone cannot do this -- the view
agents emit a spec over the pipeline and do not control its rows -- so the
reduction has to happen here, deterministically.
def _spec_field_references(spec: Any) -> set[str]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be more explicit with a package (or something) Literal['vega-lite', 'deckgl'] Literal instead

subset_gridded_to_2d and _spec_field_references now take a
kind: Literal['vega-lite', 'deckgl'] instead of sniffing both grammars at
once. The Vega-Lite path reads encoding field values and the deck.gl path
reads @@= accessor expressions, and each agent passes its own kind.
@ahuang11

ahuang11 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Does gridded_metadata work with line plots for xarray datasets?

Also, can we do a find_all and replace rename gridded_metadata to something that starts with a verb which makes code read more like choppy English, e.g. get_gridded_metadata(pipeline)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Plotting XArray Grids

3 participants