Skip to content

feat(python/sedonadb-geopandas): add column assignment, arithmetic, and dissolve - #1184

Closed
jiayuasu wants to merge 8 commits into
apache:mainfrom
jiayuasu:feature/geopandas-assign-dissolve
Closed

feat(python/sedonadb-geopandas): add column assignment, arithmetic, and dissolve#1184
jiayuasu wants to merge 8 commits into
apache:mainfrom
jiayuasu:feature/geopandas-assign-dissolve

Conversation

@jiayuasu

Copy link
Copy Markdown
Member

The unblocked half of #1142, split out so it can move forward while the spatial-join engine issues are being fixed. sjoin stays in #1142, which is blocked on #1165 (ST_Touches/ST_Within boundary misclassification) and #1166 (empty preserved side fails in the spatial join executor); nothing here touches those code paths.

gdf = sgpd.from_geopandas(points)
gdf["density"] = gdf["pop"] / gdf["area"]   # assignment + true division
zones = gdf.dissolve(by="region")           # group and union geometry

What is added

  • __setitem__ — assign a Series from the same frame, or a scalar (a geometry included). Frame provenance is enforced: a Series from another frame, a bare expression (which records no origin), and multi-element values are rejected with focused errors. Replacing the active geometry with a non-geometry clears it; assigning geometry to a frame without one activates it; a geometry scalar or missing sentinel (None, NaN, pandas.NA) keeps the column's type and CRS, and a literal carrying its own CRS keeps that rather than being relabeled.
  • Arithmetic on Series+, -, *, / and the reflected forms. / is true division as in pandas: integer-typed expressions are cast to double (decided from the projected schema, a plan build) so SQL integer division does not truncate, while decimals stay exact and durations stay durations. // is deliberately not implemented, since SQL division truncates toward zero where Python floors. Series opts out of NumPy ufunc dispatch so an array operand is rejected whole instead of being broadcast element-by-element into an object array.
  • dissolve(by, aggfunc="first", dropna=True) — unions each group's geometry via collect + unary union, which handles every geometry type (ST_Union_Agg only initializes for polygonal input, ST_Union_Agg returns NULL for point inputs #1093). dropna covers IEEE NaN as well as SQL null, including dictionary-encoded float keys. A group whose geometries are all null yields an empty geometry collection carrying the source CRS, matching GeoPandas.
  • Shared scalar machinery — one classification used by operators and assignment so they cannot disagree, with normalization: 0-d NumPy arrays unwrap to their value, pandas.NA becomes SQL null, NaN stays a float value.
  • Stale-mask guard — boolean indexing rejects a mask built from another frame, including a mask captured before an assignment rebound this one; previously that could silently filter against the wrong frame state.

Documented differences from GeoPandas

  • aggfunc="first" is an unordered aggregate: it returns some value from the group and does not skip missing values.
  • Dissolving an empty frame without a key returns one row (empty geometry collection, null attributes) rather than zero rows — a grouping-free SQL aggregate's shape.
  • A group mixing 2D and 3D geometries raises rather than being promoted to 3D.

Tests

73 tests, asserted against GeoPandas where a comparison exists (dissolve geometry equality across point/line/polygon groups, aggregation values, CRS propagation, division exactness for decimals, and the full assignment matrix including every missing-value sentinel). ruff format and ruff check clean.

…nd dissolve

The unblocked half of the sjoin/dissolve slice, split out so it can land while
the spatial-join engine issues (apache#1165, apache#1166) are being fixed; sjoin follows in
its own PR.

- __setitem__: assign a Series from the same frame, or a scalar (a geometry
  included). Frame provenance is enforced — a Series from another frame, a bare
  expression (which records no origin), and multi-element values are rejected
  with focused errors. Replacing the active geometry with a non-geometry clears
  it; giving a geometry to a frame without one activates it; a geometry scalar
  or missing sentinel (None, NaN, pandas.NA) keeps the column's type and CRS,
  and a literal that carries its own CRS keeps that instead of being relabeled.
- Arithmetic on Series (+ - * / and reflected forms), with `/` as true division:
  integer-typed expressions are cast to double (decided from the projected
  schema) so SQL integer division does not truncate, while decimals and
  durations are left untouched. NumPy ufunc dispatch is opted out of so an
  array operand is rejected whole rather than broadcast element-by-element.
- dissolve(by, aggfunc="first", dropna=True): unions each group's geometry via
  collect + unary union, which handles every geometry type (ST_Union_Agg only
  initializes for polygonal input, apache#1093); dropna covers IEEE NaN as well as SQL
  null, including dictionary-encoded float keys; an all-null group yields an
  empty geometry collection with the source CRS, matching GeoPandas.
- One scalar classification shared by operators and assignment, with
  normalization (0-d arrays unwrap, pandas.NA becomes SQL null).
- Boolean indexing rejects masks from another frame, including masks captured
  before an assignment rebound this one.

73 tests, asserted against GeoPandas where a comparison exists.
… and honor released floors

Nine issues from review.

Division resolves both operands' logical types before deciding on the double
cast: a dictionary<int64> column is unwrapped (it used to skip the cast and
truncate), and an integer divided by a Decimal stays in decimal arithmetic
instead of being forced through double. The cast now applies only to
integer/integer division.

Durations support * and / by numeric scalars, matching pandas: the value goes
through int64 ticks and back to the column's own duration type. Reusing the
source type rather than assuming nanoseconds matters — verified against released
0.4.1, which ingests durations as microseconds, where a hard-coded ns cast
silently scaled results by 1000.

Scalar handling: NumPy temporal scalars are no longer flattened to integer ticks
(datetime64 passes through, timedelta64 converts via pandas); a masked value
normalizes to missing rather than exposing its hidden payload; and Arrow scalars
are classified as scalars despite implementing __len__, so a ListScalar or
StructScalar broadcasts.

Geometry-ness in assignment is decided from the resolved literal's schema rather
than duck-typing for __geo_interface__, so a CRS-less GeoArrow WKB scalar now
inherits the column's CRS instead of clearing it.

An explicitly deactivated geometry column (geometry=None) stays inactive across
a reassignment of an existing geometry column; activation now requires the
assignment to have created a new geometry column.

dissolve(by=[]) raises "No group keys passed!" as GeoPandas does, instead of
silently collapsing the frame; by=None still dissolves all. Categorical grouping
is documented as observed-only, with a test pinning the divergence.

Dependency floors drop to sedonadb>=0.4.1 / sedonadb-expr>=0.4.0: mutate shipped
in the released 0.4.1 and expr is unchanged since 0.4.0, so the prerelease floor
was excluding a compatible release. The whole suite passes against released
0.4.1 and against current main.

Also removes two tests that duplicated newer parametrized coverage.
…ithmetic exact

Six issues from review, two of them silent data corruption.

NumPy temporal scalars are now rebuilt as typed Arrow scalars instead of being
routed through .item() or pandas: a 0-D nanosecond datetime array was stored as
raw integer ticks, a day-unit timedelta materialized as a zero duration, day-unit
datetimes failed literal construction, and temporal NaT raised. All now
materialize as the correct timestamp/duration values, and NaT becomes a typed
null of the right family. The tests assert materialization end to end rather
than helper equality, which is what let the zeroed duration hide.

Duration division by an integral operand uses exact integer tick division; the
float64 path rounded 2**53 + 1 ticks and could fail on cast-back. Fractional
operands keep floating arithmetic. Division by zero and non-finite operands
yield typed NaT, as a pandas Series does, instead of failing the int64 cast.

Type decisions look through operand wrappers: series / lit(2) and
series / pa.scalar(2) are integer division exactly as series / 2 is (they used
to truncate), and duration * lit(2) is accepted (it used to be rejected as
non-numeric). One shared resolver unwraps Literal and Arrow-scalar payloads for
both decisions.

Corrects the dependency comment: sedonadb-expr's generated surface has changed
since 0.4.0; what is true is that the APIs this wrapper calls are available in
0.4.0.

Verified against released sedonadb 0.4.1 and against current main.
Four issues from review, one of them silent corruption centuries wide.

Temporal conversion no longer forces nanoseconds. The ns range covers only
1677-2262, so an unchecked astype silently wrapped coarse-unit values —
np.datetime64("2500-01-01", "D") materialized as 1915-06-14. Arrow-native
resolutions (s, ms, us, ns) are kept exactly; whole-second units (weeks, days,
hours, minutes — plus calendar year/month positions for datetimes) convert
exactly to seconds; ambiguous timedelta months/years and sub-nanosecond units
are rejected as pandas rejects them; and a round-trip guard turns any remaining
overflow into an error rather than a wrong value.

Division of a duration by infinity is zero for valid rows with source nulls
preserved — computed as ticks * 0 — matching pandas, instead of the blanket
typed-NaT broadcast that the non-finite handling introduced. Division by zero
or NaN, and multiplication by non-finite values, still yield NaT.

The numeric resolver looks through one-element containers: lit(pa.array([2])),
lit(pd.Series([2])), and one-cell frames are single-value literals to SedonaDB,
so they are resolved via the literal's Arrow value rather than judged by their
raw Python payload. Integer division by them no longer truncates and duration
arithmetic accepts them.

Test NaT values carry explicit units, ahead of NumPy deprecating the generic
form.

Verified against released sedonadb 0.4.1 and against current main.
…s, and overflow

Accept sub-nanosecond datetimes that convert exactly to nanoseconds
(GeoPandas accepts these; lossy values are still rejected rather than
silently truncated). Raise ValueError like pandas for ambiguous or
unsupported timedelta units. Unwrap zero-dimensional object arrays before
temporal normalization so a wrapped numpy temporal is handled. Let the
literal resolver's precise errors propagate instead of replacing them
with a generic type error, and validate multi-value literals explicitly.
Gate integral duration multiplication so int64 tick overflow produces
null instead of silently wrapping (pandas 3 raises; a lazy expression
cannot raise per row), and rewrite the coarse-unit regression test
against independent second-resolution constants so it fails against the
implementation it guards.
Raise OverflowError, as pandas does, for an integer operand that itself
exceeds the signed 64-bit tick range instead of failing literal
construction with an unrelated error. Route integral-valued floats
through the exact integer path so identities like '* 1.0' stay exact at
every tick, and range-gate non-integral float results before the cast
back to ticks so out-of-range rows become null instead of aborting the
whole query. pandas clamps finite positive float overflow to
Timedelta.max while negative overflow lands on the NaT sentinel; that
asymmetric casting artifact is deliberately not copied — both directions
are missing values, and the README says so. Also test that an exactly
representable sub-nanosecond timedelta (1000 ps) is still rejected,
since the rejection is per unit rather than per value.
…d keep float parity

Nullify INT64_MIN duration ticks where ticks are derived: the value is a
representable Arrow tick but the pandas missing sentinel, and treating
it as data aborted division by -1 on arithmetic overflow, let negation
wrap back onto the sentinel where a chained multiply resurrected it as
zero, and divided it into real-looking results. Restrict the exact
integer path to the float identities 1.0 and -1.0 so non-identity floats
keep pandas' float64 semantics above 2**53 instead of silently improving
on them. Report an oversized plain-integer Literal payload as
OverflowError before the Arrow resolver fails on it with a misleading
message. Strengthen the negative float overflow test with a value that
genuinely needs the lower gate plus the exact boundary the gate must
keep, and version-qualify the README's description of pandas overflow
behavior.
…he column boundary

Nullifying INT64_MIN only inside multiplication and division left
addition, subtraction, and comparisons treating the pandas NaT sentinel
as data: self-subtraction returned zero, adding a tick produced a finite
Timedelta.min, subtracting one aborted the query on arithmetic overflow,
and equality matched, so filters retained rows that materialize as NaT.
Sanitize duration and timestamp columns where they are read instead, so
every operator inherits the invariant, and treat a sentinel-valued Arrow
temporal scalar operand the same way. Timestamps get the identical
handling because numpy-backed pandas stores datetime NaT as INT64_MIN
too. Along the way the requested regression tests exposed that pandas
Timestamp and Timedelta scalars resolved to microsecond literals —
assignment silently zeroed nanoseconds and duration arithmetic lost them
behind an interval coercion — so they now route through their numpy form
and its lossless unit handling.
@jiayuasu

Copy link
Copy Markdown
Member Author

Closing this in favor of a split into focused PRs, since the combined change grew too large to review well. The first slice (column assignment) is #1195; dissolve, arithmetic, and temporal support follow as their predecessors merge.

@jiayuasu jiayuasu closed this Aug 27, 2026
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.

1 participant