Skip to content

Commit 2f9d1d4

Browse files
committed
Document the GeoArrow stream interface
Where the GeoArrow work is visible in the public API: snail.core.intersections gained split_linestrings, split_polygons and SplitStream, and snail.intersection gained to_geoarrow and read_split_stream. The C++ docstrings already carried through Sphinx into the API reference; the Python side did not: - SPLIT_BATCH_SIZE had a substantial rationale comment that never reached the docs, since a bare assignment renders nothing - moved it to a `#:` comment, which autodoc picks up as the constant's docstring. - to_geoarrow and read_split_stream had one-line docstrings with no Parameters/Returns, unlike the rest of the module. read_split_stream in particular reads a stream to completion, which is easy to reach for without noticing it gives up the memory benefit the streaming interface exists for - the docstring now says so, and points at iterating the stream directly instead. - snail.intersection.split_linestrings, the main entry point, never documented its bounded parameter, which changes what a split returns quite a bit. Added Parameters, since it sits right next to the streaming changes and a reader working out the new behaviour needs it. None of this changes behaviour; verified by building the Sphinx docs and checking the new cross-references resolve (they do) and no new warnings appear (151 before and after, all pre-existing tutorial notebook issues). Also added docs/source/splitting-arrow.rst, linked from the main toctree. snail.intersection.split_linestrings/split_polygons already stream internally, so most users need nothing new; this page is for the two audiences who do: splitting a source that isn't already a GeoDataFrame (a GeoParquet file bigger than memory, say), and code working directly against snail.core.intersections. It explains why geometries cross the Python/C++ boundary as an Arrow stream rather than an array, what sources are accepted, and walks through splitting a GeoParquet file batch by batch without loading it whole - verified runnable against the built extension. It links out to the API reference rather than repeating it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqdWshmD4AUHhrqeMh86GS
1 parent 4a3d7c6 commit 2f9d1d4

3 files changed

Lines changed: 166 additions & 13 deletions

File tree

docs/source/index.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ Contents
114114

115115
Tutorials <tutorials>
116116

117+
.. toctree::
118+
:maxdepth: 1
119+
120+
Splitting large datasets <splitting-arrow>
121+
117122
.. toctree::
118123
:maxdepth: 3
119124

docs/source/splitting-arrow.rst

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
Splitting large datasets
2+
=========================
3+
4+
:func:`snail.intersection.split_linestrings` and
5+
:func:`snail.intersection.split_polygons` (and the ``snail split``/``snail
6+
process`` commands built on them) already do everything on this page for
7+
you: they take a :class:`~geopandas.GeoDataFrame` and give one back. Read
8+
on only if you want to split a source that is not already loaded as a
9+
GeoDataFrame - a GeoParquet file bigger than memory, say - or you are
10+
working directly against the compiled extension.
11+
12+
How a split runs
13+
-----------------
14+
15+
Geometries cross into the C++ extension as `GeoArrow
16+
<https://geoarrow.org/>`_, over the `Arrow C stream interface
17+
<https://arrow.apache.org/docs/format/CStreamInterface.html>`_: the
18+
extension pulls one batch of geometries at a time from its source, splits
19+
it, and hands the pieces back the same way, as a stream of record batches.
20+
Nothing is split until that stream is read, and only one batch of the
21+
source is ever held at once - so a file far larger than memory can be
22+
split by a consumer that takes the results as they come, without loading
23+
the whole thing first.
24+
25+
:func:`snail.core.intersections.split_linestrings` and
26+
:func:`~snail.core.intersections.split_polygons` accept *any* object
27+
implementing the Arrow PyCapsule stream interface (``__arrow_c_stream__``)
28+
or the single-array interface (``__arrow_c_array__``, read as a stream of
29+
one batch) - a :class:`pyarrow.ChunkedArray`, :class:`~pyarrow.Table` or
30+
:class:`~pyarrow.RecordBatchReader`, a GeoParquet or
31+
:class:`pyarrow.dataset.Dataset` reader, or
32+
:meth:`GeoSeries.to_arrow() <geopandas.GeoSeries.to_arrow>`. Coordinates
33+
may be interleaved (as geopandas exports them) or separated into x and y
34+
arrays (as GeoParquet stores them) - both are read directly, with no
35+
per-feature Python object built on either side of the interface.
36+
37+
Splitting a file directly
38+
--------------------------
39+
40+
This reads a GeoParquet file of linestrings in batches and splits each
41+
batch as it arrives, never holding more than one batch of geometries and
42+
one batch of pieces at a time::
43+
44+
import pyarrow
45+
import pyarrow.parquet
46+
from snail.core.intersections import split_linestrings
47+
from snail.intersection import GridDefinition
48+
49+
grid = GridDefinition.from_raster("hazard.tif")
50+
51+
parquet_file = pyarrow.parquet.ParquetFile("edges.geoparquet")
52+
geometry_batches = pyarrow.RecordBatchReader.from_batches(
53+
parquet_file.schema_arrow, parquet_file.iter_batches(batch_size=10_000)
54+
)
55+
56+
stream = split_linestrings(
57+
geometry_batches, nrows=grid.height, ncols=grid.width, transform=grid.transform
58+
)
59+
60+
reader = pyarrow.RecordBatchReader._import_from_c_capsule(stream.__arrow_c_stream__())
61+
for batch in reader:
62+
# batch has a "geometry" column of the pieces (GeoArrow-encoded)
63+
# and a "parent" column: the index, in the source, of the
64+
# geometry each piece was split from
65+
...
66+
67+
``split_linestrings`` also takes a ``bounded`` argument, working the same
68+
way as the ``bounded`` parameter of
69+
:func:`snail.intersection.split_linestrings`: pass ``bounded=True`` to
70+
leave geometries (or parts of geometries) outside the grid whole, rather
71+
than split at every gridline they would otherwise cross.
72+
73+
To get the pieces as shapely geometries instead - trading the streaming
74+
memory benefit for convenience - read the whole stream at once with
75+
:func:`snail.intersection.read_split_stream`, or convert a batch with
76+
:meth:`geopandas.GeoDataFrame.from_arrow`.
77+
78+
Reference
79+
---------
80+
81+
* :class:`snail.core.intersections.SplitStream` - what a split returns
82+
* :func:`snail.core.intersections.split_linestrings`,
83+
:func:`~snail.core.intersections.split_polygons` - the extension
84+
functions described above
85+
* :func:`snail.intersection.to_geoarrow`,
86+
:func:`snail.intersection.read_split_stream` - the conversions
87+
:func:`snail.intersection.split_linestrings` and
88+
:func:`~snail.intersection.split_polygons_experimental` use to work with
89+
an in-memory :class:`~geopandas.GeoDataFrame`

src/snail/intersection.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,19 @@
3838
from snail.tqdm_standin import tqdm_standin as tqdm
3939

4040

41-
# The extension splits one Arrow batch at a time, so an in-memory geometry
42-
# column is presented to it in batches of this many features: splitting a
43-
# whole table at once would give no sign of progress on a long job, and
44-
# would hold every piece of every feature in memory at once. A source that
45-
# brings its own batching - a GeoParquet reader, a Dataset scan - can be
46-
# split directly, and keeps whatever batch size it was read with.
47-
#
48-
# Each batch costs something fixed to set up and read back, so batches much
49-
# smaller than this measurably slow a large split down; much larger ones buy
50-
# no more speed and only raise the peak memory.
41+
#: Batch size for splitting an in-memory geometry column.
42+
#:
43+
#: The extension splits one Arrow batch at a time, so an in-memory geometry
44+
#: column is presented to it in batches of this many features: splitting a
45+
#: whole table at once would give no sign of progress on a long job, and
46+
#: would hold every piece of every feature in memory at once. A source that
47+
#: brings its own batching - a GeoParquet reader, a Dataset scan - can be
48+
#: split directly, and keeps whatever batch size it was read with; this only
49+
#: governs the batches :func:`to_geoarrow` slices an in-memory column into.
50+
#:
51+
#: Each batch costs something fixed to set up and read back, so batches much
52+
#: smaller than this measurably slow a large split down; much larger ones buy
53+
#: no more speed and only raise the peak memory.
5154
SPLIT_BATCH_SIZE = 5000
5255

5356

@@ -58,6 +61,26 @@ def to_geoarrow(geometries: geopandas.GeoSeries, batch_size: int = SPLIT_BATCH_S
5861
which the extension reads directly - no geometry object is built per
5962
feature on either side of the interface. Batches are zero-copy slices
6063
of the one Arrow array, and the geometry type travels with them.
64+
65+
This is what :func:`split_linestrings` and :func:`split_polygons_experimental`
66+
use to feed a geometry column to :func:`snail.core.intersections.split_linestrings`
67+
or :func:`snail.core.intersections.split_polygons`; call it directly
68+
only if you are working with those lower-level, Arrow-native functions
69+
yourself.
70+
71+
Parameters
72+
----------
73+
geometries: geopandas.GeoSeries
74+
Column of LineString or Polygon geometries to split.
75+
batch_size: int
76+
Number of features per batch. See :data:`SPLIT_BATCH_SIZE`.
77+
78+
Returns
79+
-------
80+
pyarrow.Table
81+
A single-column ``"geometry"`` table, chunked into batches of
82+
``batch_size`` features, implementing the Arrow PyCapsule stream
83+
interface (``__arrow_c_stream__``) that the extension consumes.
6184
"""
6285
# Import the geometry column through its capsules rather than with
6386
# pyarrow.array, which would keep the values but drop the GeoArrow
@@ -81,10 +104,34 @@ def to_geoarrow(geometries: geopandas.GeoSeries, batch_size: int = SPLIT_BATCH_S
81104

82105

83106
def read_split_stream(stream) -> tuple[numpy.ndarray, numpy.ndarray]:
84-
"""Read a stream of split pieces from the extension
107+
"""Read a stream of split pieces from the extension into memory
108+
109+
The split runs as the stream is read, a batch at a time, but this
110+
drains the stream fully and concatenates every batch: use it when you
111+
want the pieces as shapely geometries and are content to hold them all
112+
at once, which is what :func:`split_linestrings` and
113+
:func:`split_polygons_experimental` do. To keep the streaming memory
114+
benefit - splitting a source larger than memory, for example - iterate
115+
the stream yourself instead, e.g. with
116+
``pyarrow.RecordBatchReader._import_from_c_capsule(stream.__arrow_c_stream__())``,
117+
and consume each record batch as it arrives.
85118
86-
The split runs as the stream is read, a batch at a time. Returns the
87-
pieces, and for each piece the index of the geometry it came from.
119+
Parameters
120+
----------
121+
stream
122+
A :class:`snail.core.intersections.SplitStream`, or any object
123+
implementing the Arrow PyCapsule stream interface
124+
(``__arrow_c_stream__``) with a ``"geometry"`` and a ``"parent"``
125+
column, such as the result of
126+
:func:`snail.core.intersections.split_linestrings` or
127+
:func:`snail.core.intersections.split_polygons`.
128+
129+
Returns
130+
-------
131+
geometry: numpy.ndarray
132+
The split pieces, as shapely geometries.
133+
parent: numpy.ndarray
134+
For each piece, the index of the geometry it was split from.
88135
"""
89136
reader = pyarrow.RecordBatchReader._import_from_c_capsule(
90137
stream.__arrow_c_stream__()
@@ -296,6 +343,18 @@ def split_linestrings(
296343
297344
Each piece lies within a single grid cell; together the pieces of a
298345
feature are that feature, cut up, so the split conserves its length.
346+
347+
Parameters
348+
----------
349+
linestring_features: geopandas.GeoDataFrame
350+
Features to split; other columns are carried over onto each piece.
351+
grid: GridDefinition
352+
Grid to split along.
353+
bounded: bool
354+
If False (the default), a feature is split for its whole length,
355+
including any part that falls outside the grid. If True, splitting
356+
stops at the grid's edge: pieces outside the grid are left whole
357+
rather than cut at every gridline they would otherwise cross.
299358
"""
300359
# TODO check for MultiLineString
301360
# throw error or coerce (df.explode)

0 commit comments

Comments
 (0)