Skip to content

Commit cced30b

Browse files
authored
Merge pull request #455 from vieramercado/feature/local-index-file-367
Using local index file #367
2 parents de054bb + 515d5a6 commit cced30b

2 files changed

Lines changed: 155 additions & 9 deletions

File tree

src/herbie/core.py

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from io import StringIO
2020
from shutil import which
2121
from typing import Literal, Optional, Union
22+
from urllib.parse import urlparse
2223

2324
import cfgrib
2425
import pandas as pd
@@ -397,6 +398,9 @@ def _check_idx(self, url: str, verbose: bool = False) -> tuple[bool, Optional[st
397398

398399
if verbose:
399400
print(f"🐜 {self.IDX_SUFFIX=}")
401+
402+
# Initialize variable to avoid UnboundLocalError
403+
idx_exists = False
400404

401405
# Loop through IDX_SUFFIX options until we find one that exists
402406
for i in self.IDX_SUFFIX:
@@ -405,7 +409,12 @@ def _check_idx(self, url: str, verbose: bool = False) -> tuple[bool, Optional[st
405409
else:
406410
idx_url = url + i
407411

408-
idx_exists = requests.head(idx_url).ok
412+
try:
413+
idx_exists = requests.head(idx_url).ok
414+
except Exception as e:
415+
if verbose:
416+
print(f"Unable to get index file from {idx_url} due to error {str(e)}")
417+
409418
if verbose:
410419
print(f"🐜 {idx_url=}")
411420
print(f"🐜 {idx_exists=}")
@@ -431,8 +440,6 @@ def find_grib(self) -> tuple[Optional[Union[Path, str]], Optional[str]]:
431440
local_grib = self.get_localFilePath()
432441
if local_grib.exists() and not self.overwrite:
433442
return local_grib, "local"
434-
# NOTE: We will still get the idx files from a remote
435-
# because they aren't stored locally, or are they? # TODO: If the idx file is local, then use that
436443

437444
# If priority list is set, we want to search SOURCES in that
438445
# priority order. If priority is None, then search all SOURCES
@@ -465,8 +472,37 @@ def find_grib(self) -> tuple[Optional[Union[Path, str]], Optional[str]]:
465472

466473
return (None, None)
467474

468-
def find_idx(self) -> tuple[Optional[Union[Path, str]], Optional[str]]:
475+
def find_idx(self, overwrite: bool = False) -> tuple[Optional[Union[Path, str]], Optional[str]]:
469476
"""Find an index file for the GRIB file."""
477+
478+
# But first, if overwrite is False, then check if the GRIB2 inventory file exists locally.
479+
if not overwrite:
480+
local_grib = self.get_localFilePath()
481+
for suffix in self.IDX_SUFFIX:
482+
# There is no easy way to know if the index suffix needs to be appended or overwrite the grib suffix.
483+
# Hence there is a need to do various checks to verify if the index file exists locally or not.
484+
485+
# This is the case of GFS, where the grib file ends with ".grb2" and the index file is ".grb2.inv"
486+
# The index suffix needs to overwrite the grib file suffix, now ending in ".grb2.inv"
487+
if suffix.startswith(local_grib.suffix):
488+
local_idx = local_grib.with_suffix(suffix)
489+
490+
# This is the case of GFS, where the grib file ends with ".f000" and the index file is ".idx"
491+
# The index suffix needs to be appended to the grib file suffix, now ending in ".f000.idx"
492+
else:
493+
local_idx = local_grib.with_suffix(local_grib.suffix + suffix)
494+
495+
if local_idx.exists() and not self.overwrite:
496+
return (local_idx, "local")
497+
498+
# If the index file does not exists locally, we will need to try another variation of the index file name.
499+
# This is the case of IFS, where the grib file ends with ".grib2" and the index file is ".index"
500+
# The index suffix needs to overwrite the grib file suffix, now ending in ".index"
501+
else:
502+
local_idx = local_grib.with_suffix(suffix)
503+
if local_idx.exists() and not self.overwrite:
504+
return (local_idx, "local")
505+
470506
# If priority list is set, we want to search SOURCES in that
471507
# priority order. If priority is None, then search all SOURCES
472508
# in the order given by the model template file.
@@ -591,10 +627,27 @@ def get_localFilePath(
591627

592628
return localFilePath
593629

630+
def get_localIndexFilePath(self) -> Path:
631+
"""Get full path to the local index file."""
632+
633+
# Get the local file path, which creates the directory structure
634+
local_file_path = self.get_localFilePath()
635+
636+
# Get the directory in the local file path
637+
dir_path = os.path.dirname(local_file_path)
638+
639+
# Get the filename from the index URL path
640+
index_filename = os.path.basename(urlparse(self.idx).path)
641+
642+
# Create new path with the index file name
643+
index_file_path = os.path.join(dir_path, index_filename)
644+
645+
return index_file_path
646+
594647
@functools.cached_property
595648
def index_as_dataframe(self) -> pd.DataFrame:
596649
"""Read and cache the full index file."""
597-
if self.grib_source == "local" and wgrib2:
650+
if self.idx_source is None and self.grib_source == "local" and wgrib2:
598651
# Generate IDX inventory with wgrib2
599652
self.idx = StringIO(wgrib2_idx(self.get_localFilePath()))
600653
self.idx_source = "generated"
@@ -625,6 +678,7 @@ def index_as_dataframe(self) -> pd.DataFrame:
625678
read_this_idx = self.idx
626679
else:
627680
read_this_idx = None
681+
print(f"Downloading inventory file from {self.idx=}")
628682
response = requests.get(self.idx)
629683
if response.status_code != 200:
630684
response.raise_for_status()
@@ -635,9 +689,18 @@ def index_as_dataframe(self) -> pd.DataFrame:
635689
f"You will need to remake the Herbie object (H = `Herbie()`)\n"
636690
f"or delete this cached property: `del H.index_as_dataframe()`"
637691
)
692+
638693
read_this_idx = StringIO(response.text)
639694
response.close()
640695

696+
index_filepath = self.get_localIndexFilePath()
697+
os.makedirs(os.path.dirname(index_filepath), exist_ok=True)
698+
699+
with open(index_filepath, "w") as file:
700+
file.write(read_this_idx.read())
701+
# reset the cursor to the beggining of the StringIO
702+
read_this_idx.seek(0)
703+
641704
df = pd.read_csv(
642705
read_this_idx,
643706
sep=":",
@@ -697,9 +760,34 @@ def index_as_dataframe(self) -> pd.DataFrame:
697760
# eccodes keywords explained here:
698761
# https://confluence.ecmwf.int/display/UDOC/Identification+keywords
699762

700-
r = requests.get(self.idx)
701-
idxs = [json.loads(x) for x in r.text.split("\n") if x]
702-
r.close()
763+
if self.idx_source in ["local"]:
764+
with open(self.idx, "r") as file:
765+
read_this_idx = StringIO(file.read())
766+
else:
767+
print(f"Downloading inventory file from {self.idx=}")
768+
response = requests.get(self.idx)
769+
if response.status_code != 200:
770+
response.raise_for_status()
771+
response.close()
772+
raise ValueError(
773+
f"\nCant open index file {self.idx}\n"
774+
f"Download the full file first (with `H.download()`).\n"
775+
f"You will need to remake the Herbie object (H = `Herbie()`)\n"
776+
f"or delete this cached property: `del H.index_as_dataframe()`"
777+
)
778+
779+
read_this_idx = StringIO(response.text)
780+
response.close()
781+
782+
index_filepath = self.get_localIndexFilePath()
783+
os.makedirs(os.path.dirname(index_filepath), exist_ok=True)
784+
785+
with open(index_filepath, "w") as file:
786+
file.write(read_this_idx.read())
787+
# reset the cursor to the beggining of the StringIO
788+
read_this_idx.seek(0)
789+
790+
idxs = [json.loads(x) for x in read_this_idx.getvalue().split("\n") if x]
703791
df = pd.DataFrame(idxs)
704792

705793
# Format the DataFrame
@@ -1005,7 +1093,7 @@ def subset(search, outFile):
10051093
if self.overwrite and self.grib_source.startswith("local"):
10061094
# Search for the grib files on the remote archives again
10071095
self.grib, self.grib_source = self.find_grib(overwrite=True)
1008-
self.idx, self.idx_source = self.find_idx()
1096+
self.idx, self.idx_source = self.find_idx(overwrite=True)
10091097
print(f"Overwrite local file with file from [{self.grib_source}]")
10101098

10111099
# Check that data exists

tests/test_local.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Test using local directory for data files."""
2+
3+
from herbie import Herbie, config
4+
import pytest
5+
6+
DATA_DIRECTORY = config["default"]["save_dir"] / "Herbie-Tests-Data/"
7+
HERBIE_OPTIONS = {
8+
"verbose": True,
9+
"overwrite": False,
10+
"save_dir": DATA_DIRECTORY,
11+
}
12+
13+
@pytest.fixture
14+
def setup_local_data(request):
15+
date, model, product = request.param
16+
H = Herbie(date,
17+
model=model,
18+
product=product,
19+
**HERBIE_OPTIONS
20+
)
21+
22+
print(f"{H.save_dir=}")
23+
24+
H.download(verbose=True)
25+
print(f"{H.idx_source=}")
26+
27+
print(H.inventory())
28+
return date, model, product
29+
30+
31+
@pytest.mark.parametrize(
32+
"setup_local_data",
33+
[
34+
("2025-08-08", "gfs", "pgrb2.0p25"),
35+
("2020-10-27", "gfs", "0.5-degree"),
36+
("2025-08-10", "hrrr", "sfc"),
37+
("2025-08-01", "rap", "awp200"),
38+
("2025-08-05", "ifs", "wave"),
39+
],
40+
indirect=True,
41+
ids=[
42+
"gfs-2025-08-08",
43+
"gfs-2020-10-27",
44+
"hrrr-2025-08-10",
45+
"rap-2025-08-01",
46+
"ifs-2025-08-05",
47+
],
48+
)
49+
def test_local_data(setup_local_data):
50+
date, model, product = setup_local_data
51+
H = Herbie(date,
52+
model=model,
53+
product=product,
54+
**HERBIE_OPTIONS
55+
)
56+
57+
assert "local" == H.idx_source
58+
assert "local" == H.grib_source

0 commit comments

Comments
 (0)