Skip to content

Commit 35d967a

Browse files
committed
feat: handle geoarrow
1 parent 1720e74 commit 35d967a

6 files changed

Lines changed: 191 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ authors = [
2323
dependencies = [
2424
"chardet>=5.2.0",
2525
"fsspec[s3]>=2026.2.0",
26+
"geoarrow-pandas>=0.1.0",
2627
"gpxpy>=1.6.2",
2728
"numpy>=2.2.6",
2829
"pandas>=2.3.3",

src/gps_logger_parser/gps/columns.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class GPSHarmonizedColumn(str, Enum):
88
TIMESTAMP = "timestamp"
99
LATITUDE = "latitude"
1010
LONGITUDE = "longitude"
11+
GEOMETRY = "geometry"
1112
ALTITUDE = "altitude"
1213
SPEED_KM_H = "speed_km_h"
1314
TYPE = "type"
@@ -29,6 +30,7 @@ class GPSHarmonizedColumn(str, Enum):
2930
GPSHarmonizedColumn.TIMESTAMP: "datetime64[ns]",
3031
GPSHarmonizedColumn.LATITUDE: "float64",
3132
GPSHarmonizedColumn.LONGITUDE: "float64",
33+
GPSHarmonizedColumn.GEOMETRY: "geoarrow.point",
3234
GPSHarmonizedColumn.ALTITUDE: "float64",
3335
GPSHarmonizedColumn.SPEED_KM_H: "float64",
3436
GPSHarmonizedColumn.TYPE: "object",

src/gps_logger_parser/gps/jm.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ def harmonize_data(self, data):
116116
format="%d %H:%M:%S",
117117
errors="coerce",
118118
)
119+
120+
# Recreate geometry column now that lat/lon are finalized
121+
data = self._create_geometry_column(data)
122+
119123
return data
120124

121125
def _fix_content(self, data):

src/gps_logger_parser/gps/mixin.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
GPS_HARMONIZED_COLUMN_TYPES specification.
77
"""
88

9+
import geoarrow.pyarrow as ga
910
import numpy as np
1011
import pandas as pd
1112

@@ -34,11 +35,15 @@ def harmonize_data(self, data):
3435
# First, apply standard column renaming
3536
data = super().harmonize_data(data)
3637

37-
# Then ensure all GPS harmonized columns exist with correct types
38+
# Ensure all GPS harmonized columns exist with correct types
3839
for harmonized_col in GPSHarmonizedColumn:
3940
col_name = harmonized_col.value
4041
pd_dtype = GPS_HARMONIZED_COLUMN_TYPES[harmonized_col]
4142

43+
# Skip geometry column - we'll create it last from lat/lon
44+
if col_name == "geometry":
45+
continue
46+
4247
if col_name not in data.columns:
4348
# Add column with null values and appropriate dtype
4449
if pd_dtype == "float64":
@@ -65,6 +70,56 @@ def harmonize_data(self, data):
6570
elif pd_dtype == "datetime64[ns]":
6671
data[col_name] = pd.to_datetime(data[col_name], errors="coerce")
6772

73+
# Create geometry column from latitude and longitude (WGS84)
74+
# Note: This creates geometry from current lat/lon values
75+
# If parsers modify lat/lon after calling super(), they should
76+
# call _create_geometry_column() again
77+
data = self._create_geometry_column(data)
78+
79+
return data
80+
81+
def _create_geometry_column(self, data):
82+
"""
83+
Create or update geometry column from latitude and longitude.
84+
85+
This helper method can be called by subclasses after they've
86+
finished creating/modifying latitude and longitude columns.
87+
88+
Args:
89+
data: DataFrame with latitude and longitude columns
90+
91+
Returns:
92+
DataFrame with geometry column added/updated
93+
"""
94+
# Only create if lat/lon columns exist and have valid (non-null) values
95+
if (
96+
"latitude" in data.columns
97+
and "longitude" in data.columns
98+
and not data["latitude"].isna().all()
99+
and not data["longitude"].isna().all()
100+
):
101+
# Create point geometries from lat/lon coordinates
102+
# GeoArrow expects (x, y) which is (longitude, latitude)
103+
points = ga.make_point(
104+
data["longitude"].values,
105+
data["latitude"].values,
106+
crs="EPSG:4326",
107+
)
108+
# Convert to pandas ArrowExtensionArray
109+
data["geometry"] = pd.array(
110+
points.to_pylist(), dtype=pd.ArrowDtype(points.type)
111+
)
112+
else:
113+
# Create empty geometry column if lat/lon don't exist or are all null
114+
empty_points = ga.make_point(
115+
np.full(len(data), np.nan),
116+
np.full(len(data), np.nan),
117+
crs="EPSG:4326",
118+
)
119+
data["geometry"] = pd.array(
120+
empty_points.to_pylist(), dtype=pd.ArrowDtype(empty_points.type)
121+
)
122+
68123
return data
69124

70125
def get_harmonization_schema(self):

src/gps_logger_parser/tests/test_parsers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,32 @@ def test_harmonized_output_schema(file, path, file_format):
122122
f"GPS parser {file} is missing harmonized column '{col_name}'"
123123
)
124124

125+
# Verify geometry column has correct type and coordinates match lat/lon
126+
geometry_field = table.schema.field("geometry")
127+
assert "geoarrow.point" in str(geometry_field.type), (
128+
f"Geometry column should be geoarrow.point type, got {geometry_field.type} in {file}"
129+
)
130+
131+
# Convert to pandas and verify geometry coordinates match lat/lon
132+
df = table.to_pandas()
133+
if not df["latitude"].isna().all() and not df["longitude"].isna().all():
134+
# Check first non-null geometry
135+
for idx in range(min(3, len(df))):
136+
if (
137+
not df["latitude"].isna().iloc[idx]
138+
and not df["longitude"].isna().iloc[idx]
139+
):
140+
geom = df["geometry"].iloc[idx]
141+
lat = df["latitude"].iloc[idx]
142+
lon = df["longitude"].iloc[idx]
143+
assert abs(geom["x"] - lon) < 1e-6, (
144+
f"Geometry x-coordinate {geom['x']} doesn't match longitude {lon} in {file}"
145+
)
146+
assert abs(geom["y"] - lat) < 1e-6, (
147+
f"Geometry y-coordinate {geom['y']} doesn't match latitude {lat} in {file}"
148+
)
149+
break
150+
125151

126152
@pytest.mark.timeout(10)
127153
@pytest.mark.parametrize("file,path,file_format", testdata_success)

0 commit comments

Comments
 (0)