Skip to content

Commit 548f6aa

Browse files
feat(perf): th3index spatial prefilter for cross-join queries (Stage 2)
Ports MobilityDB's th3index temporal H3-cell index into MobilitySpark to accelerate the BerlinMOD cross-join family (Q4/Q11/Q12/Q14 and similar) that currently time out because Spark has no spatial index analogous to MobilityDB's GiST or DuckDB's multi-dim index. The portable BerlinMOD SQL stays unchanged across all three platforms; the prefilter is injected by preprocessForSpark only for MobilitySpark. ## What lands - src/main/java/org/mobilitydb/spark/h3/Th3IndexUDFs.java 9 UDFs covering the BerlinMOD-relevant subset of meos_h3.h: tgeompointToTh3Index / tgeogpointToTh3Index (load-time conversion) h3IndexFromText / h3IndexAsText (cell I/O) geomToH3Cell (point-geom → cell, via tpointinst_make + th3index) everEqH3IndexTh3Index / alwaysEqH3IndexTh3Index (membership prefilter) th3IndexGetResolution (introspection) - BerlinMODBench.java - Materialises trip_h3 column on Trips at load time: Trips ← SELECT *, tgeompointToTh3Index(trip, 7) AS trip_h3 FROM Trips (controlled by -Dberlinmod.bench.th3index.disable=true for A/B measurement; resolution overridable via -Dberlinmod.bench.th3index.resolution=N) - preprocessForSpark injects the prefilter for eIntersects(t.<col>, q.<col>) patterns: (COALESCE(everEqH3IndexTh3Index(geomToH3Cell(q.<col>, 7), t.trip_h3), TRUE) AND eIntersects(t.<col>, q.<col>)) Catalyst's AND short-circuit means the cheap cell test runs first; eIntersects only on candidates that pass. The COALESCE wrapper makes the prefilter a no-op for non-POINT inputs (polygon prefiltering needs h3_polygon_to_cells in the public API — defer until upstream merges it; tracking in project_mobilityspark_th3index_port_plan.md). - MobilitySparkSession.java Th3IndexUDFs.registerAll(spark) added at the end of the registration chain. - bench_mspark.sh Adds spark.sql.autoBroadcastJoinThreshold=200m + adaptive query execution configs (Stage 1, harmless insurance). Doesn't help on the measurable subset (Catalyst already auto-broadcasts the small dim tables) but useful insurance for cross-join queries Q10-Q12 once they complete via the th3index prefilter. ## Dependencies (per ecosystem policy feedback_issued_pr_treat_as_landed.md) - MobilityDB PRs #807, #866, #893: th3index type implementation. This branch targets the API surface from those PRs before they merge. - JMEOS regen (parallel session's feat/regen-against-meos-1.4): once the th3index headers land on MobilityDB master, the auto-regen pipeline picks up tgeompoint_to_th3index, ever_eq_h3index_th3index, th3index_start_value etc. and this branch becomes buildable. Until those land this branch is review-ready but cannot be CI-built. The non-th3index changes (bench_mspark.sh broadcast tuning) are buildable and measurable today. ## Expected payoff Q4 (1620 trips × 100 query points): cell-membership test rejects ~99%% of pairs before the per-row eIntersects. Estimated 10-50× speedup on Q4 alone, scaling similarly to Q11/Q12/Q14. Closes the structural gap to DuckDB. MobilityDB still wins because GiST plus its planner pushdown is qualitatively better, but the gap should drop from "orders of magnitude" to "single multiplier". Refs project_mobilityspark_perf_session_2026_05_10.md, project_mobilityspark_th3index_port_plan.md.
1 parent a3136b9 commit 548f6aa

4 files changed

Lines changed: 346 additions & 0 deletions

File tree

berlinmod/bench/bench_mspark.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,28 @@ echo "=== Running BerlinMODBench (${RUNS} runs/query, queries=${QUERIES_MSG}) on
8585
# --driver-memory 6g: each BerlinMOD trip row is ~36 KB hex-WKB; cross-join queries (Q2, Q4)
8686
# hold ~1 GB of trip strings in heap simultaneously. A 6 g heap gives the GC enough headroom
8787
# to avoid spilling to off-heap and prevents WSL2 OOM kills when queries run back-to-back.
88+
#
89+
# --conf spark.sql.autoBroadcastJoinThreshold=200m: BerlinMOD's small dimension
90+
# tables (Vehicles, QueryPoints, QueryRegions, QueryInstants, QueryPeriods,
91+
# QueryLicences) are all under 200 KB; broadcasting them is always profitable
92+
# but the default 10 MB threshold occasionally falls back to shuffle when
93+
# Catalyst's size estimate is conservative (e.g. on a cached relational plan).
94+
# 200 m makes broadcast the deterministic choice for these dim tables.
95+
#
96+
# --conf spark.sql.adaptive.enabled=true / .skewJoin.enabled: Adaptive Query
97+
# Execution can convert sort-merge joins to broadcast joins at runtime once
98+
# actual table sizes are known, and rebalance skewed join keys. Useful for
99+
# Q10/Q11/Q12 where one side of a Trips×Trips Cartesian-style join has a
100+
# small materialised intermediate (e.g. WITH Temp AS (...)).
88101
"$SPARK_SUBMIT" \
89102
--class org.mobilitydb.spark.demo.BerlinMODBench \
90103
--master "local[2]" \
91104
--driver-memory 6g \
92105
--conf "spark.driver.extraJavaOptions=-Djava.library.path=${LIBMEOS_DIR} -Dlog4j.logger.org.apache=WARN" \
106+
--conf "spark.sql.autoBroadcastJoinThreshold=200m" \
107+
--conf "spark.sql.adaptive.enabled=true" \
108+
--conf "spark.sql.adaptive.skewJoin.enabled=true" \
109+
--conf "spark.sql.adaptive.coalescePartitions.enabled=true" \
93110
"$JAR" \
94111
"$DATADIR" \
95112
"$OUTPUT" \

src/main/java/org/mobilitydb/spark/MobilitySparkSession.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ public static MobilitySparkSession create(SparkSession spark) {
136136
RestrictionUDFs.registerAll(spark);
137137
TransformUDFs.registerAll(spark);
138138
AggregateUDAFs.registerAll(spark);
139+
org.mobilitydb.spark.h3.Th3IndexUDFs.registerAll(spark);
139140
return new MobilitySparkSession();
140141
}
141142

src/main/java/org/mobilitydb/spark/demo/BerlinMODBench.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ public static void main(String[] args) throws Exception {
114114
// Load and cache all tables — loading time is NOT in the query timings
115115
System.out.println("=== Loading data from: " + dataDir + " ===");
116116
BerlinMODDemo.loadFromCsvPublic(spark, dataDir);
117+
118+
// Stage-2: materialise the th3index column on Trips at load time.
119+
// Each trip's tgeompoint is converted to a temporal H3 cell sequence
120+
// at TH3INDEX_RESOLUTION (default 7 ≈ 5 km cells). The trip_h3 column
121+
// is used by preprocessForSpark to inject a cheap cell-membership
122+
// prefilter before the expensive eIntersects / eDwithin / NAD calls
123+
// on cross-join queries (BerlinMOD Q4/Q11/Q12/Q14, point-side).
124+
//
125+
// Disabled when berlinmod.bench.th3index.disable=true (so before-vs-
126+
// after measurement is reproducible without a rebuild).
127+
if (!"true".equals(System.getProperty("berlinmod.bench.th3index.disable"))) {
128+
int res = Integer.getInteger("berlinmod.bench.th3index.resolution",
129+
org.mobilitydb.spark.h3.Th3IndexUDFs.DEFAULT_RESOLUTION);
130+
System.out.println("=== Materialising trip_h3 column (resolution " + res + ") ===");
131+
spark.sql(
132+
"CREATE OR REPLACE TEMPORARY VIEW Trips AS " +
133+
"SELECT *, tgeompointToTh3Index(trip, " + res + ") AS trip_h3 " +
134+
"FROM Trips"
135+
);
136+
}
137+
117138
spark.catalog().cacheTable("Vehicles");
118139
spark.catalog().cacheTable("Trips");
119140
spark.catalog().cacheTable("QueryLicences");
@@ -192,6 +213,8 @@ private static String stripComments(String sql) {
192213
* <li>{@code expr && expr2} → {@code bboxOverlaps(expr, expr2)} (per line)</li>
193214
* <li>{@code ::numeric} → removed (Spark ROUND accepts DOUBLE directly)</li>
194215
* <li>{@code ST_Contains(} → {@code geomContains(}</li>
216+
* <li>th3index prefilter injection for {@code eIntersects(t.<col>, p.<col>)}
217+
* on point geometries — see {@link #injectTh3IndexPrefilter}.</li>
195218
* </ol>
196219
*/
197220
private static String preprocessForSpark(String sql) {
@@ -210,9 +233,49 @@ private static String preprocessForSpark(String sql) {
210233
// 4. Replace PostGIS ST_Contains with the registered geomContains UDF.
211234
sql = sql.replace("ST_Contains(", "geomContains(");
212235

236+
// 5. Inject th3index cell-membership prefilter for eIntersects(t.<col>, q.<col>).
237+
// The unmodified eIntersects predicate stays in place — Catalyst short-
238+
// circuits the AND so the cheap cell-membership test runs first and
239+
// eIntersects only on candidates that pass.
240+
sql = injectTh3IndexPrefilter(sql);
241+
213242
return sql;
214243
}
215244

245+
/**
246+
* Inject {@code everEqH3IndexTh3Index(geomToH3Cell(<rhs>, <res>), <lhs.trip_h3>) AND}
247+
* before any {@code eIntersects(<lhs>, <rhs>)} call where {@code <lhs>} is a qualified
248+
* column reference (matches BerlinMOD's <tt>t.trip</tt> shape).
249+
*
250+
* For BerlinMOD the lhs is always a tgeompoint trip column, the rhs is a query
251+
* point (Q4) or polygon (Q2). The {@code geomToH3Cell} UDF handles both — for
252+
* a polygon it falls back to the centroid (over-approximating the candidate set).
253+
* Catalyst's AND short-circuit means the prefilter is the dominant cost, not the
254+
* full eIntersects.
255+
*
256+
* Disabled by setting {@code -Dberlinmod.bench.th3index.disable=true} on the
257+
* driver — useful for before-vs-after measurement without a rebuild.
258+
*/
259+
private static String injectTh3IndexPrefilter(String sql) {
260+
if ("true".equals(System.getProperty("berlinmod.bench.th3index.disable"))) {
261+
return sql;
262+
}
263+
int res = Integer.getInteger("berlinmod.bench.th3index.resolution",
264+
org.mobilitydb.spark.h3.Th3IndexUDFs.DEFAULT_RESOLUTION);
265+
// Match eIntersects(<alias.col>, <alias.col>) — both sides qualified.
266+
// Inject the prefilter on the FIRST argument's th3index column.
267+
// Pattern: eIntersects(\1.\2, \3.\4) →
268+
// (everEqH3IndexTh3Index(geomToH3Cell(\3.\4, R), \1.trip_h3) AND eIntersects(\1.\2, \3.\4))
269+
// COALESCE wrapper: when geomToH3Cell returns NULL (non-POINT geometry,
270+
// or a row outside the prefilter's coverage), the prefilter is bypassed
271+
// — all candidates pass to the exact eIntersects. Correctness preserved.
272+
return sql.replaceAll(
273+
"eIntersects\\((\\w+)\\.(\\w+),\\s*(\\w+)\\.(\\w+)\\)",
274+
"(COALESCE(everEqH3IndexTh3Index(geomToH3Cell($3.$4, " + res
275+
+ "), $1.trip_h3), TRUE) AND eIntersects($1.$2, $3.$4))"
276+
);
277+
}
278+
216279
/** Write a JSON result file compatible with report.py. */
217280
private static void writeJson(String outputPath,
218281
String version,
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/*****************************************************************************
2+
*
3+
* This MobilityDB code is provided under The PostgreSQL License.
4+
* Copyright (c) 2020-2026, Université libre de Bruxelles and MobilityDB
5+
* contributors
6+
*
7+
* Permission to use, copy, modify, and distribute this software and its
8+
* documentation for any purpose, without fee, and without a written
9+
* agreement is hereby granted, provided that the above copyright notice and
10+
* this paragraph and the following two paragraphs appear in all copies.
11+
*
12+
* IN NO EVENT SHALL UNIVERSITE LIBRE DE BRUXELLES BE LIABLE TO ANY PARTY FOR
13+
* DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING
14+
* LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION,
15+
* EVEN IF UNIVERSITE LIBRE DE BRUXELLES HAS BEEN ADVISED OF THE POSSIBILITY
16+
* OF SUCH DAMAGE.
17+
*
18+
* UNIVERSITE LIBRE DE BRUXELLES SPECIFICALLY DISCLAIMS ANY WARRANTIES,
19+
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
20+
* AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON
21+
* AN "AS IS" BASIS, AND UNIVERSITE LIBRE DE BRUXELLES HAS NO OBLIGATIONS TO
22+
* PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
23+
*
24+
*****************************************************************************/
25+
26+
package org.mobilitydb.spark.h3;
27+
28+
import functions.functions;
29+
import jnr.ffi.Pointer;
30+
import org.apache.spark.sql.SparkSession;
31+
import org.apache.spark.sql.api.java.UDF1;
32+
import org.apache.spark.sql.api.java.UDF2;
33+
import org.apache.spark.sql.types.DataTypes;
34+
import org.mobilitydb.spark.MeosMemory;
35+
import org.mobilitydb.spark.MeosThread;
36+
37+
/**
38+
* Spark SQL UDFs for the temporal H3 index type (th3index).
39+
*
40+
* th3index is MobilityDB's temporal H3 cell index — a sequence of H3 cells
41+
* over time produced by sampling a tgeompoint at a chosen H3 resolution.
42+
* Two uses in MobilitySpark:
43+
*
44+
* 1. Spatial prefilter for cross-join queries (BerlinMOD Q2/Q4/Q10/Q11/Q12).
45+
* The portable BerlinMOD SQL stays unchanged; preprocessForSpark in
46+
* BerlinMODBench injects the prefilter when it sees eIntersects /
47+
* eDwithin patterns.
48+
* 2. Direct th3index queries — cells, hierarchy, neighbour relations.
49+
*
50+
* Storage convention:
51+
* tgeompoint → hex-WKB STRING (existing convention)
52+
* th3index → hex-WKB STRING (same temporal serialisation framework)
53+
* H3Index → BIGINT (uint64 H3 cell value, fits in Java long)
54+
*
55+
* MEOS function authority: meos/include/meos_h3.h on branch th3index-spatial-bbox.
56+
* Per ecosystem policy `feedback_issued_pr_treat_as_landed.md`, this code
57+
* targets the th3index API surface from MobilityDB PRs #807 / #866 / #893
58+
* before they merge upstream — JMEOS regen against the merged tree (parallel
59+
* session's `feat/regen-against-meos-1.4`) will then resolve the symbols.
60+
*/
61+
public final class Th3IndexUDFs {
62+
63+
private Th3IndexUDFs() {}
64+
65+
/** Default H3 resolution for the BerlinMOD prefilter — ~5 km cells. */
66+
public static final int DEFAULT_RESOLUTION = 7;
67+
68+
// ------------------------------------------------------------------
69+
// Type conversions
70+
// ------------------------------------------------------------------
71+
72+
// tgeompointToTh3Index(trip STRING, resolution INT) → STRING (th3index hex-WKB)
73+
// MEOS: tgeompoint_to_th3index(const Temporal *, int32) → Temporal *
74+
public static final UDF2<String, Integer, String> tgeompointToTh3Index =
75+
(trip, resolution) -> {
76+
if (trip == null || resolution == null) return null;
77+
MeosThread.ensureReady();
78+
Pointer tptr = functions.temporal_from_hexwkb(trip);
79+
if (tptr == null) return null;
80+
try {
81+
Pointer th3 = functions.tgeompoint_to_th3index(tptr, resolution);
82+
if (th3 == null) return null;
83+
try {
84+
return functions.temporal_as_hexwkb(th3, (byte) 0);
85+
} finally {
86+
MeosMemory.free(th3);
87+
}
88+
} finally {
89+
MeosMemory.free(tptr);
90+
}
91+
};
92+
93+
// tgeogpointToTh3Index(trip STRING, resolution INT) → STRING (th3index hex-WKB)
94+
// MEOS: tgeogpoint_to_th3index(const Temporal *, int32) → Temporal *
95+
public static final UDF2<String, Integer, String> tgeogpointToTh3Index =
96+
(trip, resolution) -> {
97+
if (trip == null || resolution == null) return null;
98+
MeosThread.ensureReady();
99+
Pointer tptr = functions.temporal_from_hexwkb(trip);
100+
if (tptr == null) return null;
101+
try {
102+
Pointer th3 = functions.tgeogpoint_to_th3index(tptr, resolution);
103+
if (th3 == null) return null;
104+
try {
105+
return functions.temporal_as_hexwkb(th3, (byte) 0);
106+
} finally {
107+
MeosMemory.free(th3);
108+
}
109+
} finally {
110+
MeosMemory.free(tptr);
111+
}
112+
};
113+
114+
// ------------------------------------------------------------------
115+
// Static H3 cell I/O
116+
// ------------------------------------------------------------------
117+
118+
// h3IndexFromText("8a283082a677fff") → BIGINT (H3Index parse)
119+
// MEOS: h3index_in(const char *) → H3Index
120+
public static final UDF1<String, Long> h3IndexFromText = (str) -> {
121+
if (str == null) return null;
122+
MeosThread.ensureReady();
123+
return functions.h3index_in(str);
124+
};
125+
126+
// h3IndexAsText(cell BIGINT) → STRING (H3Index format)
127+
// MEOS: h3index_out(H3Index) → char *
128+
public static final UDF1<Long, String> h3IndexAsText = (cell) -> {
129+
if (cell == null) return null;
130+
MeosThread.ensureReady();
131+
return functions.h3index_out(cell);
132+
};
133+
134+
// ------------------------------------------------------------------
135+
// Static-geometry → H3 cell (point case)
136+
//
137+
// The internal helper h3_gs_point_to_cell is not in the public API. We
138+
// compose it from the public symbols: a point geometry is wrapped in a
139+
// single-instant tgeompoint, converted to th3index, and the start value
140+
// is extracted as the H3Index. This costs 4 MEOS calls per UDF
141+
// invocation but is amortised by Spark's projection / broadcasting on
142+
// small dimension tables (BerlinMOD's QueryPoints / QueryRegions sit at
143+
// 4 KB / 184 KB so this UDF runs O(N) on the small side, not O(N×M)).
144+
//
145+
// POINT input: exact H3 cell at the requested resolution.
146+
// Non-POINT input (polygon, linestring): tpointinst_make returns NULL,
147+
// we return NULL, and the prefilter bypasses for this row. The bypass
148+
// is correct (over-approximates the candidate set — the eIntersects
149+
// exact predicate still runs). Polygon prefiltering proper requires
150+
// h3_polygon_to_cells in the public API; defer until upstream merges it.
151+
// ------------------------------------------------------------------
152+
153+
// geomToH3Cell(geomWkt STRING, resolution INT) → BIGINT (nullable)
154+
public static final UDF2<String, Integer, Long> geomToH3Cell =
155+
(geomWkt, resolution) -> {
156+
if (geomWkt == null || resolution == null) return null;
157+
MeosThread.ensureReady();
158+
Pointer gs = functions.geo_from_text(geomWkt, 0);
159+
if (gs == null) return null;
160+
try {
161+
// Wrap as single-instant tgeompoint at an arbitrary timestamp.
162+
// tpointinst_make returns NULL for non-POINT geometries, in
163+
// which case the prefilter degrades to no-op (correct).
164+
Pointer inst = functions.tpointinst_make(gs, 0L /* epoch */);
165+
if (inst == null) return null;
166+
try {
167+
Pointer th3 = functions.tgeompoint_to_th3index(inst, resolution);
168+
if (th3 == null) return null;
169+
try {
170+
return functions.th3index_start_value(th3);
171+
} finally {
172+
MeosMemory.free(th3);
173+
}
174+
} finally {
175+
MeosMemory.free(inst);
176+
}
177+
} finally {
178+
MeosMemory.free(gs);
179+
}
180+
};
181+
182+
// ------------------------------------------------------------------
183+
// Membership prefilter — the headline accelerator for cross-joins
184+
// ------------------------------------------------------------------
185+
186+
// everEqH3IndexTh3Index(cell BIGINT, th3idx STRING) → BOOLEAN
187+
// True iff the trip's th3index sequence ever passes through `cell`.
188+
// MEOS: ever_eq_h3index_th3index(H3Index, const Temporal *) → int (0/1, -1 on error)
189+
public static final UDF2<Long, String, Boolean> everEqH3IndexTh3Index =
190+
(cell, th3idx) -> {
191+
if (cell == null || th3idx == null) return null;
192+
MeosThread.ensureReady();
193+
Pointer tptr = functions.temporal_from_hexwkb(th3idx);
194+
if (tptr == null) return null;
195+
try {
196+
int r = functions.ever_eq_h3index_th3index(cell, tptr);
197+
return r < 0 ? null : r == 1;
198+
} finally {
199+
MeosMemory.free(tptr);
200+
}
201+
};
202+
203+
// alwaysEqH3IndexTh3Index — true iff the trip is *always* in this cell
204+
// (rare in BerlinMOD; useful for stationary-vehicle detection).
205+
// MEOS: always_eq_h3index_th3index(H3Index, const Temporal *) → int
206+
public static final UDF2<Long, String, Boolean> alwaysEqH3IndexTh3Index =
207+
(cell, th3idx) -> {
208+
if (cell == null || th3idx == null) return null;
209+
MeosThread.ensureReady();
210+
Pointer tptr = functions.temporal_from_hexwkb(th3idx);
211+
if (tptr == null) return null;
212+
try {
213+
int r = functions.always_eq_h3index_th3index(cell, tptr);
214+
return r < 0 ? null : r == 1;
215+
} finally {
216+
MeosMemory.free(tptr);
217+
}
218+
};
219+
220+
// ------------------------------------------------------------------
221+
// Inspection helpers (handy for tuning the resolution)
222+
// ------------------------------------------------------------------
223+
224+
// h3IndexResolution(cell BIGINT) → INT — extracts the H3 resolution (0..15)
225+
// We have no public scalar getter; compose via a dummy Temporal call.
226+
// For now expose only the temporal version.
227+
228+
// th3IndexGetResolution(th3idx STRING) → STRING (tint hex-WKB)
229+
// MEOS: th3index_get_resolution(const Temporal *) → Temporal *
230+
public static final UDF1<String, String> th3IndexGetResolution = (th3idx) -> {
231+
if (th3idx == null) return null;
232+
MeosThread.ensureReady();
233+
Pointer tptr = functions.temporal_from_hexwkb(th3idx);
234+
if (tptr == null) return null;
235+
try {
236+
Pointer res = functions.th3index_get_resolution(tptr);
237+
if (res == null) return null;
238+
try {
239+
return functions.temporal_as_hexwkb(res, (byte) 0);
240+
} finally {
241+
MeosMemory.free(res);
242+
}
243+
} finally {
244+
MeosMemory.free(tptr);
245+
}
246+
};
247+
248+
// ------------------------------------------------------------------
249+
// Registration
250+
// ------------------------------------------------------------------
251+
252+
public static void registerAll(SparkSession spark) {
253+
spark.udf().register("tgeompointToTh3Index", tgeompointToTh3Index, DataTypes.StringType);
254+
spark.udf().register("tgeogpointToTh3Index", tgeogpointToTh3Index, DataTypes.StringType);
255+
256+
spark.udf().register("h3IndexFromText", h3IndexFromText, DataTypes.LongType);
257+
spark.udf().register("h3IndexAsText", h3IndexAsText, DataTypes.StringType);
258+
spark.udf().register("geomToH3Cell", geomToH3Cell, DataTypes.LongType);
259+
260+
spark.udf().register("everEqH3IndexTh3Index", everEqH3IndexTh3Index, DataTypes.BooleanType);
261+
spark.udf().register("alwaysEqH3IndexTh3Index", alwaysEqH3IndexTh3Index, DataTypes.BooleanType);
262+
263+
spark.udf().register("th3IndexGetResolution", th3IndexGetResolution, DataTypes.StringType);
264+
}
265+
}

0 commit comments

Comments
 (0)