Skip to content

unr_grid

UNR Grid GPS data source implementation.

UnrGridSource

Bases: BaseGpsSource

UNR Grid GPS data source for gridded time series data.

Source code in src/geepers/gps_sources/unr_grid.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
class UnrGridSource(BaseGpsSource):
    """UNR Grid GPS data source for gridded time series data."""

    def __init__(
        self,
        version: Literal["0.1", "0.3"] = DEFAULT_VERSION,
        gridded_type: Literal["constant", "variable"] = "variable",
        cache_dir: str | Path | None = None,
    ):
        """Initialize UNR Grid GPS data source.

        Parameters
        ----------
        version : Literal["0.1", "0.3"], optional
            Version of the UNR grid data to use. Default is "0.3".
        gridded_type : Literal["constant", "variable"], optional
            Whether to use the time-constant or time-variable gridded
            products. Only available starting with version "0.3";
            version "0.1" only has time-variable data. Default is "variable".
        cache_dir : str | Path, optional
            Directory to cache downloaded data files.

        """
        # Initialize BaseGpsSource
        super().__init__(cache_dir=cache_dir)

        # Store UNR version
        self.version = version
        self.gridded_type = gridded_type

    def timeseries(
        self,
        station_id: str,
        /,
        frame: Literal["ENU", "XYZ"] = "ENU",
        start_date: str | None = None,
        end_date: str | None = None,
        zero_by: Literal["mean", "start", "none"] = "mean",
        download_if_missing: bool = True,
        *,
        plate: Literal["NA", "PA", "IGS14", "IGS20"] = "IGS14",
    ) -> pd.DataFrame:
        """Load grid point time series data.

        Parameters
        ----------
        station_id : str
            The grid point identifier (6-digit string).
        frame : {"ENU", "XYZ"}, optional
            Coordinate frame for the data. Default is "ENU".
        start_date : str, optional
            Start date for data filtering (ISO format). Currently not implemented.
        end_date : str, optional
            End date for data filtering (ISO format). Currently not implemented.
        zero_by : Literal["mean", "start"], optional
            How to zero the data. Either "mean" or "start".
        download_if_missing : bool, optional
            Whether to download data if not found locally. Currently not implemented.
        plate : Literal["NA", "PA", "IGS14", "IGS20"], optional
            Plate for the data. Default is "IGS14".

        Returns
        -------
        pd.DataFrame
            DataFrame with ENU time series data validated against schema.

        Raises
        ------
        ValueError
            If XYZ frame is requested (not supported for grid data).

        """
        if frame == "XYZ":
            msg = "XYZ frame not supported for grid data"
            raise ValueError(msg)

        # TODO: how to handle fetching/saving, vs using pandas to read...
        if download_if_missing:
            local_file = self._download_file(
                station_id,
                plate=plate,
                version=self.version,
                gridded_type=self.gridded_type,
            )
            df = self.parse_data_file(local_file)
        else:
            filename = FILENAME_TEMPLATE.format(plate=plate, grid_id=int(station_id))
            gridded_dir = GRIDDED_TYPE_DIRS[
                self.gridded_type if self.version != "0.1" else "variable"
            ]
            uri = GRID_DATA_BASE_URL.format(
                version=self.version, gridded_dir=gridded_dir, filename=filename
            )
            df = self.parse_data_file(uri)

        df = self._filter_by_date(df, start_date, end_date)
        df = self._zero_data(df, zero_by, columns=["east", "north", "up"])
        return StationObservationSchema.validate(df, lazy=True)

    def _read_station_data(self) -> gpd.GeoDataFrame:
        """Read raw grid point data from the source.

        Returns
        -------
        gpd.GeoDataFrame
            GeoDataFrame with grid point metadata including lon, lat, alt columns.

        """
        df = self._read_grid_file(version=self.version)

        # Rename columns to match expected format
        df_out = df.reset_index()
        df_out = df_out.rename(
            columns={"grid_point": "id", "latitude": "lat", "longitude": "lon"}
        )
        df_out["alt"] = 0.0  # Grid points don't have altitude info

        # Convert to GeoDataFrame
        gdf = gpd.GeoDataFrame(
            df_out,
            geometry=gpd.points_from_xy(df_out.lon, df_out.lat),
            crs="EPSG:4326",
        )

        return gdf

    def stations(
        self,
        bbox: tuple[float, float, float, float] | None = None,
        mask: gpd.GeoSeries | None = None,
    ) -> gpd.GeoDataFrame:
        """Get grid points, optionally filtered by spatial bounds.

        Parameters
        ----------
        bbox : tuple[float, float, float, float], optional
            Bounding box as (west, south, east, north) in degrees.
        mask : gpd.GeoSeries, optional
            Spatial mask to filter grid points.

        Returns
        -------
        gpd.GeoDataFrame
            GeoDataFrame with grid point metadata including lon, lat, alt columns.

        """
        # Get data using base class method
        gdf = super().stations(bbox, mask)

        # Apply grid-specific schema validation
        GridCellSchema.validate(gdf, lazy=True)

        return gdf

    def _download_file(
        self,
        grid_id: str,
        plate: Literal["NA", "PA", "IGS14", "IGS20"] = "IGS14",
        output_dir: Path | None = None,
        session: requests.Session | None = None,
        version: Literal["0.1", "0.3"] = DEFAULT_VERSION,
        gridded_type: Literal["constant", "variable"] = "variable",
    ) -> Path:
        """Download ont .tenv8 data file.

        Parameters
        ----------
        grid_id: str
            Grid point ID to download.
        plate : Literal["NA", "PA", "IGS14", "IGS20], optional
            Plate for the data. Default is "IGS14".
        output_dir : Path | None, optional
            Directory to store downloaded data files.
            If None, the cache directory is used.
        session : requests.Session
            A shared requests.Session object.
            Can be used for retrying.
        version : Literal["0.1", "0.3"], optional
            Version of the UNR grid data to download.
        gridded_type : Literal["constant", "variable"], optional
            Whether to download the time-constant or time-variable
            gridded product. Version "0.1" only has time-variable data,
            so this is ignored when version is "0.1".

        Returns
        -------
        Path
            Paths to downloaded data files.

        """
        if output_dir is None:
            output_dir = self._cache_dir
        output_dir.mkdir(parents=True, exist_ok=True)

        if plate == "IGS14" and version == "0.3":
            # Warning: IGS14 plate is not available in UNR version 0.3.
            # Using IGS20 instead.
            plate = "IGS20"

        gridded_dir = GRIDDED_TYPE_DIRS[
            gridded_type if version != "0.1" else "variable"
        ]
        # Accept both int and zero-padded string ids ("000123" or 123)
        filename = FILENAME_TEMPLATE.format(plate=plate, grid_id=int(grid_id))
        url = GRID_DATA_BASE_URL.format(
            version=version, gridded_dir=gridded_dir, filename=filename
        )
        dest = output_dir / url.rsplit("/", 1)[-1]
        if not dest.exists():
            if session is None:
                resp = requests.get(url, timeout=REQUEST_TIMEOUT)
            else:
                resp = session.get(url, timeout=REQUEST_TIMEOUT)
            resp.raise_for_status()
            with dest.open("wb") as f:
                f.write(resp.content)
        return dest

    def download_data_files(
        self,
        grid_id_list: list[str] | None = None,
        plate: Literal["NA", "PA", "IGS14", "IGS20"] = "IGS14",
        max_workers: int = 8,
        output_dir: Path | None = None,
        version: Literal["0.1", "0.3"] = DEFAULT_VERSION,
        gridded_type: Literal["constant", "variable"] = "variable",
    ) -> list[Path]:
        """Download .tenv8 data files in parallel, showing progress.

        Parameters
        ----------
        grid_id_list : list[str], optional
            Specific grid point IDs to download.
            If None, all grid points are downloaded.
        plate : Literal["NA", "PA", "IGS14", "IGS20"], optional
            Plate for the data. Default is "IGS14".
        max_workers : int, optional
            Number of threads to use for downloading in parallel.
        output_dir : Path | None, optional
            Directory to store downloaded data files.
            If None, the cache directory is used.
        version : Literal["0.1", "0.3"], optional
            Version of the UNR grid data to download. Default is "0.3".
        gridded_type : Literal["constant", "variable"], optional
            Whether to download the time-constant or time-variable
            gridded product. Ignored when version is "0.1", since that
            version only has time-variable data.

        Returns
        -------
        list[Path]
            List of paths to downloaded data files.

        """
        if output_dir is None:
            output_dir = self._cache_dir
        output_dir.mkdir(parents=True, exist_ok=True)
        if grid_id_list is None:
            grid_id_list = self.stations().index.tolist()

        s = requests.Session()
        retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504])
        s.mount("https://", HTTPAdapter(max_retries=retries))

        return thread_map(
            self._download_file,
            grid_id_list,
            plate=plate,
            output_dir=output_dir,
            max_workers=max_workers,
            session=s,
            desc="Downloading data files",
            version=version,
            gridded_type=gridded_type,
        )

    @staticmethod
    @lru_cache(maxsize=128)
    def _read_data_file(uri: str | Path) -> pd.DataFrame:
        df = pd.read_csv(
            uri,
            sep=r"\s+",
            header=None,
            names=[
                "decimal_year",
                "east",
                "north",
                "up",
                "sigma_east",
                "sigma_north",
                "sigma_up",
                "rapid_flag",
            ],
        )
        return df

    def parse_data_file(self, uri: str | Path) -> pd.DataFrame:
        """Parse a .tenv8 time-series data file into a DataFrame.

        Parameters
        ----------
        uri : str | Path
            Path or URL to the .tenv8 file.

        Returns
        -------
        pd.DataFrame
            DataFrame with columns validated against GPSUncertaintySchema.

        """
        # Copy so we never mutate the lru_cached DataFrame
        df = self._read_data_file(uri).copy()

        # Convert decimal year to datetime (vectorized)
        df["date"] = decimal_years_to_datetimes(df["decimal_year"])

        # Add placeholder correlation values (not in .tenv8 format)
        df["corr_en"] = 0.0
        df["corr_eu"] = 0.0
        df["corr_nu"] = 0.0

        # Select relevant columns for validation
        df_out = df[
            [
                "date",
                "east",
                "north",
                "up",
                "sigma_east",
                "sigma_north",
                "sigma_up",
                "corr_en",
                "corr_eu",
                "corr_nu",
            ]
        ]
        # UNR Grid is in millimeters instead of meters (values and sigmas):
        sigma_cols = ["sigma_east", "sigma_north", "sigma_up"]
        df_out.loc[:, ["east", "north", "up", *sigma_cols]] /= 1000

        # Time-constant gridded products can report exactly-zero sigmas,
        # which the schema rejects; clamp to its minimum
        df_out.loc[:, sigma_cols] = df_out[sigma_cols].clip(lower=EPS)

        return StationObservationSchema.validate(df_out, lazy=True)

    @staticmethod
    @cache
    def _read_grid_file(version: str = DEFAULT_VERSION) -> pd.DataFrame:
        """Download and cache the UNR grid latitude/longitude lookup table."""
        if version not in VALID_VERSIONS:
            msg = (
                f"Unsupported version '{version}'. "
                f"Valid options are: {', '.join(VALID_VERSIONS)}."
            )
            raise ValueError(msg)

        url = LOOKUP_FILE_URL.format(version=version)
        df = pd.read_csv(
            url,
            sep=r"\s+",
            names=["grid_point", "longitude", "latitude"],
        )
        if version == "0.3":
            # Version 0.3 maps longitudes from 0 to 360
            # convert to -180 to 180, to be consistent with version 0.1
            df["longitude"] = ((df["longitude"] + 180) % 360) - 180
        return df.set_index("grid_point")

__init__(version=DEFAULT_VERSION, gridded_type='variable', cache_dir=None)

Initialize UNR Grid GPS data source.

Parameters:

Name Type Description Default
version Literal['0.1', '0.3']

Version of the UNR grid data to use. Default is "0.3".

DEFAULT_VERSION
gridded_type Literal['constant', 'variable']

Whether to use the time-constant or time-variable gridded products. Only available starting with version "0.3"; version "0.1" only has time-variable data. Default is "variable".

'variable'
cache_dir str | Path

Directory to cache downloaded data files.

None
Source code in src/geepers/gps_sources/unr_grid.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def __init__(
    self,
    version: Literal["0.1", "0.3"] = DEFAULT_VERSION,
    gridded_type: Literal["constant", "variable"] = "variable",
    cache_dir: str | Path | None = None,
):
    """Initialize UNR Grid GPS data source.

    Parameters
    ----------
    version : Literal["0.1", "0.3"], optional
        Version of the UNR grid data to use. Default is "0.3".
    gridded_type : Literal["constant", "variable"], optional
        Whether to use the time-constant or time-variable gridded
        products. Only available starting with version "0.3";
        version "0.1" only has time-variable data. Default is "variable".
    cache_dir : str | Path, optional
        Directory to cache downloaded data files.

    """
    # Initialize BaseGpsSource
    super().__init__(cache_dir=cache_dir)

    # Store UNR version
    self.version = version
    self.gridded_type = gridded_type

download_data_files(grid_id_list=None, plate='IGS14', max_workers=8, output_dir=None, version=DEFAULT_VERSION, gridded_type='variable')

Download .tenv8 data files in parallel, showing progress.

Parameters:

Name Type Description Default
grid_id_list list[str]

Specific grid point IDs to download. If None, all grid points are downloaded.

None
plate Literal['NA', 'PA', 'IGS14', 'IGS20']

Plate for the data. Default is "IGS14".

'IGS14'
max_workers int

Number of threads to use for downloading in parallel.

8
output_dir Path | None

Directory to store downloaded data files. If None, the cache directory is used.

None
version Literal['0.1', '0.3']

Version of the UNR grid data to download. Default is "0.3".

DEFAULT_VERSION
gridded_type Literal['constant', 'variable']

Whether to download the time-constant or time-variable gridded product. Ignored when version is "0.1", since that version only has time-variable data.

'variable'

Returns:

Type Description
list[Path]

List of paths to downloaded data files.

Source code in src/geepers/gps_sources/unr_grid.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def download_data_files(
    self,
    grid_id_list: list[str] | None = None,
    plate: Literal["NA", "PA", "IGS14", "IGS20"] = "IGS14",
    max_workers: int = 8,
    output_dir: Path | None = None,
    version: Literal["0.1", "0.3"] = DEFAULT_VERSION,
    gridded_type: Literal["constant", "variable"] = "variable",
) -> list[Path]:
    """Download .tenv8 data files in parallel, showing progress.

    Parameters
    ----------
    grid_id_list : list[str], optional
        Specific grid point IDs to download.
        If None, all grid points are downloaded.
    plate : Literal["NA", "PA", "IGS14", "IGS20"], optional
        Plate for the data. Default is "IGS14".
    max_workers : int, optional
        Number of threads to use for downloading in parallel.
    output_dir : Path | None, optional
        Directory to store downloaded data files.
        If None, the cache directory is used.
    version : Literal["0.1", "0.3"], optional
        Version of the UNR grid data to download. Default is "0.3".
    gridded_type : Literal["constant", "variable"], optional
        Whether to download the time-constant or time-variable
        gridded product. Ignored when version is "0.1", since that
        version only has time-variable data.

    Returns
    -------
    list[Path]
        List of paths to downloaded data files.

    """
    if output_dir is None:
        output_dir = self._cache_dir
    output_dir.mkdir(parents=True, exist_ok=True)
    if grid_id_list is None:
        grid_id_list = self.stations().index.tolist()

    s = requests.Session()
    retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504])
    s.mount("https://", HTTPAdapter(max_retries=retries))

    return thread_map(
        self._download_file,
        grid_id_list,
        plate=plate,
        output_dir=output_dir,
        max_workers=max_workers,
        session=s,
        desc="Downloading data files",
        version=version,
        gridded_type=gridded_type,
    )

parse_data_file(uri)

Parse a .tenv8 time-series data file into a DataFrame.

Parameters:

Name Type Description Default
uri str | Path

Path or URL to the .tenv8 file.

required

Returns:

Type Description
DataFrame

DataFrame with columns validated against GPSUncertaintySchema.

Source code in src/geepers/gps_sources/unr_grid.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def parse_data_file(self, uri: str | Path) -> pd.DataFrame:
    """Parse a .tenv8 time-series data file into a DataFrame.

    Parameters
    ----------
    uri : str | Path
        Path or URL to the .tenv8 file.

    Returns
    -------
    pd.DataFrame
        DataFrame with columns validated against GPSUncertaintySchema.

    """
    # Copy so we never mutate the lru_cached DataFrame
    df = self._read_data_file(uri).copy()

    # Convert decimal year to datetime (vectorized)
    df["date"] = decimal_years_to_datetimes(df["decimal_year"])

    # Add placeholder correlation values (not in .tenv8 format)
    df["corr_en"] = 0.0
    df["corr_eu"] = 0.0
    df["corr_nu"] = 0.0

    # Select relevant columns for validation
    df_out = df[
        [
            "date",
            "east",
            "north",
            "up",
            "sigma_east",
            "sigma_north",
            "sigma_up",
            "corr_en",
            "corr_eu",
            "corr_nu",
        ]
    ]
    # UNR Grid is in millimeters instead of meters (values and sigmas):
    sigma_cols = ["sigma_east", "sigma_north", "sigma_up"]
    df_out.loc[:, ["east", "north", "up", *sigma_cols]] /= 1000

    # Time-constant gridded products can report exactly-zero sigmas,
    # which the schema rejects; clamp to its minimum
    df_out.loc[:, sigma_cols] = df_out[sigma_cols].clip(lower=EPS)

    return StationObservationSchema.validate(df_out, lazy=True)

stations(bbox=None, mask=None)

Get grid points, optionally filtered by spatial bounds.

Parameters:

Name Type Description Default
bbox tuple[float, float, float, float]

Bounding box as (west, south, east, north) in degrees.

None
mask GeoSeries

Spatial mask to filter grid points.

None

Returns:

Type Description
GeoDataFrame

GeoDataFrame with grid point metadata including lon, lat, alt columns.

Source code in src/geepers/gps_sources/unr_grid.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def stations(
    self,
    bbox: tuple[float, float, float, float] | None = None,
    mask: gpd.GeoSeries | None = None,
) -> gpd.GeoDataFrame:
    """Get grid points, optionally filtered by spatial bounds.

    Parameters
    ----------
    bbox : tuple[float, float, float, float], optional
        Bounding box as (west, south, east, north) in degrees.
    mask : gpd.GeoSeries, optional
        Spatial mask to filter grid points.

    Returns
    -------
    gpd.GeoDataFrame
        GeoDataFrame with grid point metadata including lon, lat, alt columns.

    """
    # Get data using base class method
    gdf = super().stations(bbox, mask)

    # Apply grid-specific schema validation
    GridCellSchema.validate(gdf, lazy=True)

    return gdf

timeseries(station_id, /, frame='ENU', start_date=None, end_date=None, zero_by='mean', download_if_missing=True, *, plate='IGS14')

Load grid point time series data.

Parameters:

Name Type Description Default
station_id str

The grid point identifier (6-digit string).

required
frame ('ENU', 'XYZ')

Coordinate frame for the data. Default is "ENU".

"ENU"
start_date str

Start date for data filtering (ISO format). Currently not implemented.

None
end_date str

End date for data filtering (ISO format). Currently not implemented.

None
zero_by Literal['mean', 'start']

How to zero the data. Either "mean" or "start".

'mean'
download_if_missing bool

Whether to download data if not found locally. Currently not implemented.

True
plate Literal['NA', 'PA', 'IGS14', 'IGS20']

Plate for the data. Default is "IGS14".

'IGS14'

Returns:

Type Description
DataFrame

DataFrame with ENU time series data validated against schema.

Raises:

Type Description
ValueError

If XYZ frame is requested (not supported for grid data).

Source code in src/geepers/gps_sources/unr_grid.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def timeseries(
    self,
    station_id: str,
    /,
    frame: Literal["ENU", "XYZ"] = "ENU",
    start_date: str | None = None,
    end_date: str | None = None,
    zero_by: Literal["mean", "start", "none"] = "mean",
    download_if_missing: bool = True,
    *,
    plate: Literal["NA", "PA", "IGS14", "IGS20"] = "IGS14",
) -> pd.DataFrame:
    """Load grid point time series data.

    Parameters
    ----------
    station_id : str
        The grid point identifier (6-digit string).
    frame : {"ENU", "XYZ"}, optional
        Coordinate frame for the data. Default is "ENU".
    start_date : str, optional
        Start date for data filtering (ISO format). Currently not implemented.
    end_date : str, optional
        End date for data filtering (ISO format). Currently not implemented.
    zero_by : Literal["mean", "start"], optional
        How to zero the data. Either "mean" or "start".
    download_if_missing : bool, optional
        Whether to download data if not found locally. Currently not implemented.
    plate : Literal["NA", "PA", "IGS14", "IGS20"], optional
        Plate for the data. Default is "IGS14".

    Returns
    -------
    pd.DataFrame
        DataFrame with ENU time series data validated against schema.

    Raises
    ------
    ValueError
        If XYZ frame is requested (not supported for grid data).

    """
    if frame == "XYZ":
        msg = "XYZ frame not supported for grid data"
        raise ValueError(msg)

    # TODO: how to handle fetching/saving, vs using pandas to read...
    if download_if_missing:
        local_file = self._download_file(
            station_id,
            plate=plate,
            version=self.version,
            gridded_type=self.gridded_type,
        )
        df = self.parse_data_file(local_file)
    else:
        filename = FILENAME_TEMPLATE.format(plate=plate, grid_id=int(station_id))
        gridded_dir = GRIDDED_TYPE_DIRS[
            self.gridded_type if self.version != "0.1" else "variable"
        ]
        uri = GRID_DATA_BASE_URL.format(
            version=self.version, gridded_dir=gridded_dir, filename=filename
        )
        df = self.parse_data_file(uri)

    df = self._filter_by_date(df, start_date, end_date)
    df = self._zero_data(df, zero_by, columns=["east", "north", "up"])
    return StationObservationSchema.validate(df, lazy=True)