Skip to content

Commit c28abee

Browse files
committed
chg: consistent gps output, with original data in json column
1 parent 9bce327 commit c28abee

10 files changed

Lines changed: 235 additions & 211 deletions

File tree

src/gps_logger_parser/gps/columns.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class GPSHarmonizedColumn(str, Enum):
1818
PDOP = "pdop"
1919
SATELLITES_COUNT = "satellites_count"
2020
TEMPERATURE = "temperature"
21-
SOLAR_I_MA = "solar_I_mA"
21+
SOLAR_I_MA = "solar_i_ma"
2222
BAT_SOC_PCT = "bat_soc_pct"
2323
RING_NR = "ring_nr"
2424
TRIP_NR = "trip_nr"
@@ -30,7 +30,6 @@ class GPSHarmonizedColumn(str, Enum):
3030
GPSHarmonizedColumn.TIMESTAMP: "datetime64[ns]",
3131
GPSHarmonizedColumn.LATITUDE: "float64",
3232
GPSHarmonizedColumn.LONGITUDE: "float64",
33-
GPSHarmonizedColumn.GEOMETRY: "geoarrow.point",
3433
GPSHarmonizedColumn.ALTITUDE: "float64",
3534
GPSHarmonizedColumn.SPEED_KM_H: "float64",
3635
GPSHarmonizedColumn.TYPE: "object",
@@ -44,4 +43,5 @@ class GPSHarmonizedColumn(str, Enum):
4443
GPSHarmonizedColumn.BAT_SOC_PCT: "float64",
4544
GPSHarmonizedColumn.RING_NR: "object",
4645
GPSHarmonizedColumn.TRIP_NR: "Int64",
46+
GPSHarmonizedColumn.GEOMETRY: "object",
4747
}

src/gps_logger_parser/gps/ecotone.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,46 @@
1+
import geoarrow.pyarrow as ga
2+
import numpy as np
3+
import pandas as pd
4+
15
from ..parser_base import CSVParser
6+
from .columns import GPSHarmonizedColumn
27
from .mixin import GPSHarmonizationMixin
38

49

510
class EcotoneParser(GPSHarmonizationMixin, CSVParser):
611
DATATYPE = "gps_ecotone"
7-
FIELDS = [x for x in range(0, 9)]
12+
FIELDS = [
13+
"logger_id",
14+
"day",
15+
"month",
16+
"year",
17+
"hours",
18+
"minutes",
19+
"meters_north",
20+
"meters_east",
21+
"HDOP",
22+
]
823
SEPARATOR = ";"
924
HEADER = 0
25+
MAPPINGS = {
26+
GPSHarmonizedColumn.ID: "logger_id",
27+
GPSHarmonizedColumn.TIMESTAMP: None,
28+
GPSHarmonizedColumn.LATITUDE: None,
29+
GPSHarmonizedColumn.LONGITUDE: None,
30+
GPSHarmonizedColumn.ALTITUDE: None,
31+
GPSHarmonizedColumn.SPEED_KM_H: None,
32+
GPSHarmonizedColumn.TYPE: None,
33+
GPSHarmonizedColumn.DISTANCE: None,
34+
GPSHarmonizedColumn.COURSE: None,
35+
GPSHarmonizedColumn.HDOP: "HDOP",
36+
GPSHarmonizedColumn.PDOP: None,
37+
GPSHarmonizedColumn.SATELLITES_COUNT: None,
38+
GPSHarmonizedColumn.TEMPERATURE: None,
39+
GPSHarmonizedColumn.SOLAR_I_MA: None,
40+
GPSHarmonizedColumn.BAT_SOC_PCT: None,
41+
GPSHarmonizedColumn.RING_NR: None,
42+
GPSHarmonizedColumn.TRIP_NR: None,
43+
}
1044

1145
def _check_headers(self, header):
1246
if len(header) != len(self.FIELDS):
@@ -15,6 +49,56 @@ def _check_headers(self, header):
1549
f"{len(header)} != {len(self.FIELDS)}"
1650
)
1751

52+
def harmonize_data(self, data):
53+
54+
# Call parent harmonization — applies MAPPINGS, enforces GPS schema,
55+
# creates geometry, and drops raw source columns
56+
result = super().harmonize_data(data)
57+
58+
result["timestamp"] = pd.to_datetime(
59+
data["year"].astype(str)
60+
+ "/"
61+
+ data["month"].astype(str)
62+
+ "/"
63+
+ data["day"].astype(str)
64+
+ " "
65+
+ data["hours"].astype(str)
66+
+ ":"
67+
+ data["minutes"].astype(str),
68+
format="%Y/%m/%d %H:%M",
69+
errors="raise",
70+
)
71+
72+
if (
73+
"meters_north" in data.columns
74+
and "meters_east" in data.columns
75+
and not data["meters_north"].isna().all()
76+
and not data["meters_east"].isna().all()
77+
):
78+
# Create point geometries from meters_north/meters_east coordinates
79+
# GeoArrow expects (x, y) which is (longitude, latitude)
80+
points = ga.make_point(
81+
data["meters_east"].str[:-1].astype(float).values,
82+
data["meters_north"].str[:-1].astype(float).values,
83+
crs="EPSG:32633", # UTM zone 33N
84+
)
85+
# Convert to pandas ArrowExtensionArray
86+
result["geometry"] = pd.array(
87+
points.to_pylist(), dtype=pd.ArrowDtype(points.type)
88+
)
89+
else:
90+
# Create empty geometry column if lat/lon don't exist or are all null
91+
empty_points = ga.make_point(
92+
np.full(len(data), np.nan),
93+
np.full(len(data), np.nan),
94+
crs="EPSG:32633", # UTM zone 33N
95+
)
96+
result["geometry"] = pd.array(
97+
empty_points.to_pylist(), dtype=pd.ArrowDtype(empty_points.type)
98+
)
99+
100+
return result
101+
18102

19103
PARSERS = [
20104
EcotoneParser,

src/gps_logger_parser/gps/ho11.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ def harmonize_data(self, data):
5252
errors="raise",
5353
format="%d.%m.%Y %H:%M:%S",
5454
)
55+
56+
for column in [
57+
"Latitude",
58+
"Longitude",
59+
"Altitude",
60+
"Speed",
61+
"Course",
62+
"Distance",
63+
]:
64+
try:
65+
data[column] = data[column].str.replace(",", ".", regex=False)
66+
data[column] = pd.to_numeric(data[column], errors="coerce")
67+
except AttributeError:
68+
pass
69+
5570
return super().harmonize_data(data)
5671

5772

src/gps_logger_parser/gps/jm.py

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ class GPS2JMParser7_5(GPSHarmonizationMixin, Parser):
2626
"""
2727

2828
DATATYPE = "gps_2jm"
29-
# TODO: define fields
30-
FIELDS = [str(x) for x in range(0, 13)]
3129
VERSION = "v7.5"
3230
SEPARATOR = " "
3331
ENDINGS = [
@@ -55,14 +53,14 @@ class GPS2JMParser7_5(GPSHarmonizationMixin, Parser):
5553
GPSHarmonizedColumn.TIMESTAMP: None,
5654
GPSHarmonizedColumn.LATITUDE: None,
5755
GPSHarmonizedColumn.LONGITUDE: None,
58-
GPSHarmonizedColumn.ALTITUDE: None,
59-
GPSHarmonizedColumn.SPEED_KM_H: None,
56+
GPSHarmonizedColumn.ALTITUDE: "altitude",
57+
GPSHarmonizedColumn.SPEED_KM_H: "speed",
6058
GPSHarmonizedColumn.TYPE: None,
61-
GPSHarmonizedColumn.DISTANCE: None,
59+
GPSHarmonizedColumn.DISTANCE: "distance",
6260
GPSHarmonizedColumn.COURSE: None,
6361
GPSHarmonizedColumn.HDOP: None,
6462
GPSHarmonizedColumn.PDOP: None,
65-
GPSHarmonizedColumn.SATELLITES_COUNT: None,
63+
GPSHarmonizedColumn.SATELLITES_COUNT: "satellite",
6664
GPSHarmonizedColumn.TEMPERATURE: None,
6765
GPSHarmonizedColumn.SOLAR_I_MA: None,
6866
GPSHarmonizedColumn.BAT_SOC_PCT: None,
@@ -71,56 +69,49 @@ class GPS2JMParser7_5(GPSHarmonizationMixin, Parser):
7169
}
7270

7371
def harmonize_data(self, data):
74-
# Combine start date with time from each row
75-
# start_date is in format DD.MM.YYYY, time is HH:MM:SS
76-
# Call parent harmonization first
77-
data = super().harmonize_data(data)
72+
# Call parent harmonization — applies MAPPINGS, enforces GPS schema,
73+
# creates geometry, and drops raw source columns
74+
result = super().harmonize_data(data)
7875

7976
# Convert coordinates from degrees + decimal minutes to decimal degrees
77+
# before calling super(), so the harmonized lat/lon are in decimal degrees.
8078
# Latitude: degrees + (minutes / 60), with direction sign
8179
lat_decimal_degrees = (
82-
data["__original__latitude"].astype(int)
83-
+ data["__original__latitude_decimal"].astype(float) / 60
80+
data["latitude"].astype(int) + data["latitude_decimal"].astype(float) / 60
8481
)
85-
data["latitude"] = [
82+
result["latitude"] = [
8683
signed(lat, direction)
87-
for lat, direction in zip(
88-
lat_decimal_degrees, data["__original__n"], strict=False
89-
)
84+
for lat, direction in zip(lat_decimal_degrees, data["n"], strict=False)
9085
]
9186

9287
# Longitude: degrees + (minutes / 60), with direction sign
9388
lon_decimal_degrees = (
94-
data["__original__longitude"].astype(int)
95-
+ data["__original__longitude_decimal"].astype(float) / 60
89+
data["longitude"].astype(int) + data["longitude_decimal"].astype(float) / 60
9690
)
97-
data["longitude"] = [
91+
result["longitude"] = [
9892
signed(lon, direction)
99-
for lon, direction in zip(
100-
lon_decimal_degrees, data["__original__e"], strict=False
101-
)
93+
for lon, direction in zip(lon_decimal_degrees, data["e"], strict=False)
10294
]
10395

104-
# Then create the timestamp column after original columns are prefixed
96+
# Build timestamp column before calling super()
10597
if hasattr(self, "start_date") and self.start_date:
106-
# time column is now __original__time
107-
data["timestamp"] = pd.to_datetime(
108-
self.start_date + " " + data["__original__time"],
98+
result["timestamp"] = pd.to_datetime(
99+
self.start_date + " " + data["time"],
109100
format="%d.%m.%Y %H:%M:%S",
110-
errors="raise",
101+
errors="coerce",
111102
)
112103
else:
113104
# Fallback if start_date not available
114-
data["timestamp"] = pd.to_datetime(
115-
data["__original__date"].astype(str) + " " + data["__original__time"],
105+
result["timestamp"] = pd.to_datetime(
106+
data["date"].astype(str) + " " + data["time"],
116107
format="%d %H:%M:%S",
117-
errors="raise",
108+
errors="coerce",
118109
)
119110

120111
# Recreate geometry column now that lat/lon are finalized
121-
data = self._create_geometry_column(data)
112+
result = self._create_geometry_column(result)
122113

123-
return data
114+
return result
124115

125116
def _fix_content(self, data):
126117
return data
@@ -284,17 +275,18 @@ class GPS2JMParser8Alternative(GPSHarmonizationMixin, Parser):
284275
}
285276

286277
def harmonize_data(self, data):
287-
# Combine UTC_date and UTC_time columns into timestamp
288-
# Call parent harmonization first
289-
data = super().harmonize_data(data)
290278

291-
# Then create timestamp from the prefixed original columns
292-
data["timestamp"] = pd.to_datetime(
293-
data["__original__UTC_date"] + " " + data["__original__UTC_time"],
279+
# Call parent harmonization — applies MAPPINGS, enforces GPS schema,
280+
# creates geometry, and drops raw source columns
281+
result = super().harmonize_data(data)
282+
283+
# Build timestamp from raw UTC_date and UTC_time columns
284+
result["timestamp"] = pd.to_datetime(
285+
data["UTC_date"] + " " + data["UTC_time"],
294286
format="%d.%m.%Y %H:%M:%S",
295287
errors="raise",
296288
)
297-
return data
289+
return result
298290

299291
def _fix_content(self, data: str):
300292
"""

src/gps_logger_parser/gps/mixin.py

Lines changed: 5 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import numpy as np
1111
import pandas as pd
1212

13-
from .columns import GPS_HARMONIZED_COLUMN_TYPES, GPSHarmonizedColumn
13+
from .columns import GPS_HARMONIZED_COLUMN_TYPES
1414

1515

1616
class GPSHarmonizationMixin:
@@ -35,47 +35,11 @@ def harmonize_data(self, data):
3535
# First, apply standard column renaming
3636
data = super().harmonize_data(data)
3737

38-
# Ensure all GPS harmonized columns exist with correct types
39-
for harmonized_col in GPSHarmonizedColumn:
40-
col_name = harmonized_col.value
41-
pd_dtype = GPS_HARMONIZED_COLUMN_TYPES[harmonized_col]
42-
43-
# Skip geometry column - we'll create it last from lat/lon
44-
if col_name == "geometry":
45-
continue
46-
47-
if col_name not in data.columns:
48-
# Add column with null values and appropriate dtype
49-
if pd_dtype == "float64":
50-
data[col_name] = np.nan
51-
elif pd_dtype == "datetime64[ns]":
52-
data[col_name] = pd.NaT
53-
else:
54-
data[col_name] = None
55-
56-
# Ensure column has the correct dtype
57-
col_exists = col_name in data.columns
58-
if col_exists and data[col_name].dtype != pd_dtype:
59-
# Convert to correct dtype, handling potential type conversion issues
60-
if pd_dtype == "object":
61-
if data[col_name].dtype != "object":
62-
data[col_name] = data[col_name].astype(str)
63-
elif pd_dtype == "float64":
64-
data[col_name] = pd.to_numeric(data[col_name], errors="raise")
65-
elif pd_dtype == "Int64":
66-
# Convert to numeric first, then to nullable Int64
67-
data[col_name] = pd.to_numeric(data[col_name], errors="raise")
68-
# Round to handle floating point values before conversion
69-
data[col_name] = data[col_name].round().astype("Int64")
70-
elif pd_dtype == "datetime64[ns]":
71-
data[col_name] = pd.to_datetime(data[col_name], errors="raise")
72-
7338
# Create geometry column from latitude and longitude (WGS84)
7439
# Note: This creates geometry from current lat/lon values
7540
# If parsers modify lat/lon after calling super(), they should
7641
# call _create_geometry_column() again
7742
data = self._create_geometry_column(data)
78-
7943
return data
8044

8145
def _create_geometry_column(self, data):
@@ -126,4 +90,7 @@ def get_harmonization_schema(self):
12690
"""
12791
Return None - we use pandas types directly, not PyArrow schemas
12892
"""
129-
return None
93+
return {
94+
harmonized_col.value: pd_dtype
95+
for harmonized_col, pd_dtype in GPS_HARMONIZED_COLUMN_TYPES.items()
96+
}

src/gps_logger_parser/gps/pathtrack.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ class PathtrackParser(GPSHarmonizationMixin, Parser):
5656
def harmonize_data(self, data):
5757
# Combine date and time fields into timestamp
5858
data["timestamp"] = pd.to_datetime(
59-
data["year"].astype(str)
59+
"20"
60+
+ data["year"].astype(str)
6061
+ "/"
6162
+ data["month"].astype(str)
6263
+ "/"
@@ -164,7 +165,8 @@ class CSVPathtrack(GPSHarmonizationMixin, CSVParser):
164165
def harmonize_data(self, data):
165166
# Combine date and time fields into a timestamp
166167
data["timestamp"] = pd.to_datetime(
167-
data["year"].astype(str)
168+
"20" # There are just the last 2 digits of the year
169+
+ data["year"].astype(str)
168170
+ "/"
169171
+ data["month"].astype(str)
170172
+ "/"
@@ -178,6 +180,19 @@ def harmonize_data(self, data):
178180
format="%Y/%m/%d %H:%M:%S",
179181
errors="raise",
180182
)
183+
184+
for column in [
185+
"latitude",
186+
"longitude",
187+
"altitude",
188+
]:
189+
try:
190+
data[column] = data[column].str.replace(",", ".", regex=False)
191+
data[column] = pd.to_numeric(data[column], errors="coerce")
192+
except AttributeError:
193+
# this fails only when the column is already numeric
194+
pass
195+
181196
return super().harmonize_data(data)
182197

183198

0 commit comments

Comments
 (0)