Skip to content

Commit 93102a9

Browse files
committed
refactor reduce duplicated code
1 parent 64c30cf commit 93102a9

5 files changed

Lines changed: 199 additions & 42 deletions

File tree

climakitae/new_core/param_validation/clip_param_validator.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
from climakitae.new_core.param_validation.param_validation_tools import (
1818
_get_closest_options,
1919
)
20-
from climakitae.new_core.processors.processor_utils import is_station_identifier
20+
from climakitae.new_core.processors.processor_utils import (
21+
find_station_match,
22+
is_station_identifier,
23+
)
2124

2225

2326
@register_processor_validator("clip")
@@ -450,23 +453,8 @@ def _validate_station_identifier(value: str) -> bool:
450453
)
451454
return False
452455

453-
# Normalize the input
454-
station_id_upper = value.upper().strip()
455-
456-
# Try exact match on ID column
457-
match = stations_df[stations_df["ID"].str.upper() == station_id_upper]
458-
459-
if len(match) == 0:
460-
# Try matching on station name (full station column)
461-
match = stations_df[stations_df["station"].str.upper() == station_id_upper]
462-
463-
if len(match) == 0:
464-
# Try partial match on station name
465-
match = stations_df[
466-
stations_df["station"]
467-
.str.upper()
468-
.str.contains(station_id_upper, na=False)
469-
]
456+
# Use the generalized matching logic
457+
match = find_station_match(value, stations_df)
470458

471459
if len(match) == 0:
472460
# Station not found - provide suggestions from both stations and boundaries
@@ -648,7 +636,7 @@ def _warn_about_case_sensitivity(value: str) -> bool:
648636
hint = f"\nNote: '{value}' is all lowercase. If this is a state abbreviation, it should probably be uppercase (e.g., '{value.upper()}')."
649637
elif "county" in value.lower() and not value.endswith("County"):
650638
hint = "\nNote: County names typically end with 'County' (e.g., 'Los Angeles County')."
651-
639+
652640
warnings.warn(
653641
f"\n\nBoundary key '{value}' may have case sensitivity issues. "
654642
f"\nDid you mean any of the following options: "

climakitae/new_core/processors/clip.py

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@
127127
DataProcessor,
128128
register_processor,
129129
)
130-
from climakitae.new_core.processors.processor_utils import is_station_identifier
130+
from climakitae.new_core.processors.processor_utils import (
131+
find_station_match,
132+
is_station_identifier,
133+
)
131134
from climakitae.util.utils import get_closest_gridcell, get_closest_gridcells
132135

133136

@@ -443,23 +446,8 @@ def _get_station_coordinates(
443446
if stations_df is None or len(stations_df) == 0:
444447
raise RuntimeError("Station data is not available in the catalog.")
445448

446-
# Normalize the input
447-
station_id_upper = station_identifier.upper().strip()
448-
449-
# Try exact match on ID column
450-
match = stations_df[stations_df["ID"].str.upper() == station_id_upper]
451-
452-
if len(match) == 0:
453-
# Try matching on station name (full station column)
454-
match = stations_df[stations_df["station"].str.upper() == station_id_upper]
455-
456-
if len(match) == 0:
457-
# Try partial match on station name
458-
match = stations_df[
459-
stations_df["station"]
460-
.str.upper()
461-
.str.contains(station_id_upper, na=False)
462-
]
449+
# Use the generalized matching logic
450+
match = find_station_match(station_identifier, stations_df)
463451

464452
if len(match) == 0:
465453
# Station not found - provide suggestions
@@ -474,7 +462,7 @@ def _get_station_coordinates(
474462

475463
error_msg = f"Station '{station_identifier}' not found in station database."
476464
if suggestions:
477-
error_msg += f"\n\nDid you mean one of these?\n - " + "\n - ".join(
465+
error_msg += "\n\nDid you mean one of these?\n - " + "\n - ".join(
478466
suggestions[:5]
479467
)
480468
error_msg += (

climakitae/new_core/processors/processor_utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,86 @@ def is_station_identifier(value: str) -> bool:
8686
return False
8787

8888

89+
def find_station_match(station_identifier: str, stations_df):
90+
"""
91+
Find matching station(s) in the stations DataFrame.
92+
93+
This function centralizes the station matching logic used by both the Clip
94+
processor and the clip parameter validator. It tries multiple matching strategies
95+
in order of specificity:
96+
1. Exact match on station ID column
97+
2. Exact match on station name column
98+
3. Partial match on station name column
99+
100+
Parameters
101+
----------
102+
station_identifier : str
103+
Station identifier to search for (e.g., "KSAC", "Sacramento (KSAC)", "Sacramento")
104+
stations_df : pd.DataFrame
105+
DataFrame containing station data with columns: ID, station, city, state, LAT_Y, LON_X
106+
107+
Returns
108+
-------
109+
pd.DataFrame
110+
DataFrame containing matching station(s). May have 0, 1, or multiple rows:
111+
- Empty (len=0): No matches found
112+
- Single row (len=1): Exact match found
113+
- Multiple rows (len>1): Multiple stations match the identifier
114+
115+
Notes
116+
-----
117+
The caller is responsible for:
118+
- Checking if stations_df is None or empty before calling
119+
- Handling the different match scenarios (no match, single match, multiple matches)
120+
- Providing appropriate error messages or warnings based on context
121+
122+
Examples
123+
--------
124+
>>> # For validation (clip_param_validator.py)
125+
>>> match = find_station_match("KSAC", stations_df)
126+
>>> if len(match) == 0:
127+
... # Handle no match - provide suggestions
128+
>>> elif len(match) > 1:
129+
... # Handle multiple matches - ask user to be more specific
130+
>>> else:
131+
... # Valid single match
132+
... return True
133+
134+
>>> # For coordinate extraction (clip.py)
135+
>>> match = find_station_match("KSAC", stations_df)
136+
>>> if len(match) == 0:
137+
... # Raise ValueError with suggestions
138+
>>> elif len(match) > 1:
139+
... # Raise ValueError asking for more specific identifier
140+
>>> else:
141+
... # Extract coordinates and metadata
142+
... lat = float(match.iloc[0]["LAT_Y"])
143+
... lon = float(match.iloc[0]["LON_X"])
144+
"""
145+
# Normalize the input
146+
station_id_upper = station_identifier.upper().strip()
147+
148+
# Handle empty string after normalization
149+
if not station_id_upper:
150+
# Return empty DataFrame with same structure
151+
return stations_df.iloc[0:0]
152+
153+
# Try exact match on ID column
154+
match = stations_df[stations_df["ID"].str.upper() == station_id_upper]
155+
156+
if len(match) == 0:
157+
# Try matching on station name (full station column)
158+
match = stations_df[stations_df["station"].str.upper() == station_id_upper]
159+
160+
if len(match) == 0:
161+
# Try partial match on station name
162+
match = stations_df[
163+
stations_df["station"].str.upper().str.contains(station_id_upper, na=False)
164+
]
165+
166+
return match
167+
168+
89169
def _get_block_maxima_optimized(
90170
da_series: xr.DataArray,
91171
extremes_type: str = "max",

tests/new_core/param_validation/test_clip_param_validation.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,9 @@ def test_validate_list_all_items_invalid_returns_false(self, mock_validate):
344344
# Mock returns None for invalid items (the code checks `if validated_item is not None`)
345345
mock_validate.return_value = None
346346

347-
with pytest.warns(UserWarning, match="Found \\d+ invalid items in clip parameter list"):
347+
with pytest.warns(
348+
UserWarning, match="Found \\d+ invalid items in clip parameter list"
349+
):
348350
result = _validate_list_param(["INVALID1", "INVALID2"])
349351
assert result is False
350352

@@ -651,9 +653,7 @@ def test_warn_no_match_warns_and_returns_false(self, mock_data_catalog_class):
651653
assert result is False
652654

653655
@patch("climakitae.new_core.param_validation.clip_param_validator.DataCatalog")
654-
def test_warn_lowercase_state_abbreviation_adds_hint(
655-
self, mock_data_catalog_class
656-
):
656+
def test_warn_lowercase_state_abbreviation_adds_hint(self, mock_data_catalog_class):
657657
"""Test _warn_about_case_sensitivity with lowercase state abbreviation adds hint to suggestion warning."""
658658
mock_catalog = MagicMock()
659659
mock_data_catalog_class.return_value = mock_catalog

tests/new_core/processors/test_processor_utils.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
_optimize_chunking_for_block_maxima,
3232
_set_block_maxima_attributes,
3333
extend_time_domain,
34+
find_station_match,
3435
)
3536

3637

@@ -1455,3 +1456,103 @@ def test_mixed_data_types(self):
14551456
assert isinstance(result, xr.DataArray)
14561457
# Should preserve data type characteristics
14571458
assert result.dtype.kind in ["i", "f"] # integer or float
1459+
1460+
1461+
class TestFindStationMatch:
1462+
"""Test class for find_station_match utility function."""
1463+
1464+
def setup_method(self):
1465+
"""Set up test fixtures."""
1466+
# Create a mock stations DataFrame matching the structure of real station data
1467+
self.stations_df = pd.DataFrame(
1468+
{
1469+
"ID": ["KSAC", "KBFL", "KSFO", "KSAN", "KOAK"],
1470+
"station": [
1471+
"Sacramento (KSAC)",
1472+
"Bakersfield (KBFL)",
1473+
"San Francisco International (KSFO)",
1474+
"San Diego International (KSAN)",
1475+
"Oakland International (KOAK)",
1476+
],
1477+
"city": [
1478+
"Sacramento",
1479+
"Bakersfield",
1480+
"San Francisco",
1481+
"San Diego",
1482+
"Oakland",
1483+
],
1484+
"state": ["CA", "CA", "CA", "CA", "CA"],
1485+
"LAT_Y": [38.5, 35.4, 37.6, 32.7, 37.7],
1486+
"LON_X": [-121.5, -119.1, -122.4, -117.2, -122.2],
1487+
}
1488+
)
1489+
1490+
def test_exact_match_on_id(self):
1491+
"""Test exact match on station ID."""
1492+
match = find_station_match("KSAC", self.stations_df)
1493+
1494+
assert len(match) == 1
1495+
assert match.iloc[0]["ID"] == "KSAC"
1496+
assert match.iloc[0]["city"] == "Sacramento"
1497+
1498+
def test_exact_match_case_insensitive(self):
1499+
"""Test exact match is case-insensitive."""
1500+
match = find_station_match("ksac", self.stations_df)
1501+
1502+
assert len(match) == 1
1503+
assert match.iloc[0]["ID"] == "KSAC"
1504+
1505+
def test_exact_match_with_whitespace(self):
1506+
"""Test exact match handles leading/trailing whitespace."""
1507+
match = find_station_match(" KSAC ", self.stations_df)
1508+
1509+
assert len(match) == 1
1510+
assert match.iloc[0]["ID"] == "KSAC"
1511+
1512+
def test_exact_match_on_station_name(self):
1513+
"""Test exact match on station name column."""
1514+
match = find_station_match("Sacramento (KSAC)", self.stations_df)
1515+
1516+
assert len(match) == 1
1517+
assert match.iloc[0]["ID"] == "KSAC"
1518+
1519+
def test_partial_match_on_station_name(self):
1520+
"""Test partial match on station name."""
1521+
match = find_station_match("Sacramento", self.stations_df)
1522+
1523+
assert len(match) == 1
1524+
assert match.iloc[0]["ID"] == "KSAC"
1525+
1526+
def test_partial_match_multiple_results(self):
1527+
"""Test partial match returning multiple results."""
1528+
# Add another station with "International" in the name
1529+
match = find_station_match("International", self.stations_df)
1530+
1531+
# Should match San Francisco International, San Diego International, Oakland International
1532+
assert len(match) == 3
1533+
assert all("International" in name for name in match["station"].values)
1534+
1535+
def test_no_match(self):
1536+
"""Test when no match is found."""
1537+
match = find_station_match("KZZZ", self.stations_df)
1538+
1539+
assert len(match) == 0
1540+
1541+
def test_no_match_invalid_name(self):
1542+
"""Test when station name doesn't match anything."""
1543+
match = find_station_match("Nonexistent City", self.stations_df)
1544+
1545+
assert len(match) == 0
1546+
1547+
def test_empty_identifier(self):
1548+
"""Test with empty string identifier."""
1549+
# After strip(), empty string won't match anything
1550+
match = find_station_match("", self.stations_df)
1551+
1552+
assert len(match) == 0
1553+
1554+
def test_whitespace_only_identifier(self):
1555+
"""Test with whitespace-only identifier."""
1556+
match = find_station_match(" ", self.stations_df)
1557+
1558+
assert len(match) == 0

0 commit comments

Comments
 (0)