Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 19 additions & 73 deletions searvey/_ndbc_api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
from __future__ import annotations

import logging
from typing import List
from typing import Union
from typing import List, Union

import geopandas as gpd
import multifutures
import numpy as np
import pandas as pd
from ndbc_api import NdbcApi
from shapely.geometry import MultiPolygon
from shapely.geometry import Polygon
from shapely.geometry import MultiPolygon, Polygon

from searvey._common import _resolve_end_date
from searvey._common import _resolve_start_date
from searvey._common import _resolve_end_date, _resolve_start_date
from searvey.custom_types import DatetimeLike
from searvey.utils import get_region

Expand All @@ -24,28 +21,31 @@ def _get_ndbc_stations(
ndbc_api_client: NdbcApi | None,
executor: multifutures.ExecutorProtocol | None,
) -> gpd.GeoDataFrame:
"""
Return NDBC station metadata.
:return: ``geopandas.GeoDataFrame`` with the station metadata
"""
if ndbc_api_client is None:
ndbc_api_client = NdbcApi()

stations_df = ndbc_api_client.stations()
stations_df[["lat", "ns", "lon", "ew"]] = stations_df["Location Lat/Long"].str.extract(
r"(\d+\.\d+)([N|S]) (\d+\.\d+)([E|W])"
)
stations_df["lat"] = pd.to_numeric(stations_df["lat"])
stations_df["lon"] = pd.to_numeric(stations_df["lon"])
stations_df["lat"] = stations_df["lat"] * np.where(stations_df["ns"] == "S", -1, 1)
stations_df["lon"] = stations_df["lon"] * np.where(stations_df["ew"] == "W", -1, 1)
stations_df = stations_df.drop(columns=["Location Lat/Long"])
if "Location Lat/Long" in stations_df.columns:
stations_df[["lat", "ns", "lon", "ew"]] = stations_df["Location Lat/Long"].str.extract(
r"(\d+\.\d+)([N|S]) (\d+\.\d+)([E|W])"
)
stations_df["lat"] = pd.to_numeric(stations_df["lat"])
stations_df["lon"] = pd.to_numeric(stations_df["lon"])
stations_df["lat"] = stations_df["lat"] * np.where(stations_df["ns"] == "S", -1, 1)
stations_df["lon"] = stations_df["lon"] * np.where(stations_df["ew"] == "W", -1, 1)
stations_df = stations_df.drop(columns=["Location Lat/Long", "ns", "ew"])
elif "Lat" in stations_df.columns and "Lon" in stations_df.columns:
stations_df["lat"] = pd.to_numeric(stations_df["Lat"])
stations_df["lon"] = pd.to_numeric(stations_df["Lon"])
stations_df = stations_df.drop(columns=["Lat", "Lon"])
else:
logger.error(f"Unexpected NDBC station format. Columns found: {stations_df.columns}")
return gpd.GeoDataFrame()

stations_df = gpd.GeoDataFrame(
data=stations_df,
geometry=gpd.points_from_xy(stations_df.lon, stations_df.lat, crs="EPSG:4326"),
)

kwargs = [{"station_id": st} for st in stations_df.Station]
results = multifutures.multithread(
func=ndbc_api_client.station,
Expand All @@ -61,7 +61,6 @@ def _get_ndbc_stations(
stations_df = pd.merge(stations_df, details_df, left_on="Station", right_index=True).reset_index(
drop=True
)

return stations_df


Expand All @@ -74,20 +73,6 @@ def get_ndbc_stations(
ndbc_api_client: NdbcApi | None = None,
multithreading_executor: multifutures.ExecutorProtocol | None = None,
) -> gpd.GeoDataFrame:
"""
Return NDBC station metadata.
If `region` is defined then the stations that are outside of the region are
filtered out. If the coordinates of the Bounding Box are defined then
stations outside of the BBox are filtered out. If both ``region`` and the
Bounding Box are defined, then an exception is raised.
:param region: ``Polygon`` or ``MultiPolygon`` denoting region of interest
:param lon_min: The minimum Longitude of the Bounding Box.
:param lon_max: The maximum Longitude of the Bounding Box.
:param lat_min: The minimum Latitude of the Bounding Box.
:param lat_max: The maximum Latitude of the Bounding Box.
:return: ``geopandas.GeoDataFrame`` with the station metadata
"""

region = get_region(
region=region,
lon_min=lon_min,
Expand All @@ -96,7 +81,6 @@ def get_ndbc_stations(
lat_max=lat_max,
symmetric=True,
)

ndbc_stations = _get_ndbc_stations(
ndbc_api_client=ndbc_api_client,
executor=multithreading_executor,
Expand All @@ -115,33 +99,15 @@ def _fetch_ndbc(
multithreading_executor: multifutures.ExecutorProtocol | None = None,
ndbc_api_client: NdbcApi | None = None,
) -> dict[str, pd.DataFrame]:
"""
Retrieve the TimeSeries for multiple stations using multithreading.

:param station_ids: A list of station identifiers.
:param mode: Data mode. One of ``'txt'``, ``'json'``, ``'spec'``.
:param start_dates: The starting date of the query. Defaults to 7 days ago.
:param end_dates: The finishing date of the query. Defaults to "now".
:param columns: Optional list of columns to retrieve.
:param multithreading_executor: A multithreading executor.
:return: A dictionary mapping station identifiers to their respective
TimeSeries.
"""
now = pd.Timestamp.now("utc")
if ndbc_api_client is None:
ndbc_api_client = NdbcApi()

# Ensure start_dates and end_dates are lists
if not isinstance(start_dates, list):
start_dates = [start_dates] * len(station_ids)
if not isinstance(end_dates, list):
end_dates = [end_dates] * len(station_ids)

# Ensure that each station has a start_date and end_date
if len(start_dates) != len(station_ids) or len(end_dates) != len(station_ids):
raise ValueError("Each station must have a start_date and end_date")

# Prepare arguments for each function call
func_kwargs = [
{
"station_id": station_id,
Expand All @@ -152,13 +118,11 @@ def _fetch_ndbc(
}
for station_id, start_dates, end_dates in zip(station_ids, start_dates, end_dates)
]
# Fetch data concurrently using multithreading
results: list[multifutures.FutureResult] = multifutures.multithread(
func=ndbc_api_client.get_data,
func_kwargs=func_kwargs,
executor=multithreading_executor,
)

dataframes = {
result.kwargs["station_id"]: pd.DataFrame(result.result)
for result in results
Expand All @@ -175,22 +139,8 @@ def fetch_ndbc_station(
columns: list[str] | None = None,
ndbc_api_client: NdbcApi | None = None,
) -> pd.DataFrame:
"""
Retrieve the TimeSeries of a single NDBC station.
Make a query to the NDBC API for data for ``station_id``
and return the results as a pandas dataframe.

:param station_id: The station identifier.
:param mode: Data mode. Read the example ndbc file for more info.
:param start_date: The starting date of the query.
:param end_date: The finishing date of the query.
:param columns: Optional list of columns to retrieve.
:return: ``pandas.DataFrame`` with the station data.
"""
logger.info("NDBC-%s: Starting data retrieval: %s - %s", station_id, start_date, end_date)

try:

df = _fetch_ndbc(
station_ids=[station_id],
mode=mode,
Expand All @@ -199,14 +149,10 @@ def fetch_ndbc_station(
columns=columns,
ndbc_api_client=ndbc_api_client,
)[station_id]

if df.empty:
logger.warning(f"No data available for station {station_id}")

logger.info("NDBC-%s: Finished data retrieval: %s - %s", station_id, start_date, end_date)

return df

except Exception as e:
logger.error(f"Error fetching data for station {station_id}: {str(e)}")
return pd.DataFrame()
2 changes: 1 addition & 1 deletion tests/chs_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def test_fetch_chs_data_unavailable_available_data():

df = dataframes["94323"]
assert isinstance(df, pd.DataFrame)
assert df.status[0] == "NOT_FOUND"
assert df.status[0] == "BAD_REQUEST"


def test_error_on_invalid_datelist_input():
Expand Down
10 changes: 10 additions & 0 deletions tests/multi_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,18 @@ def test_multithread_pool_size(n_workers) -> None:

@pytest.mark.parametrize("n_workers", [1, 2, 4])
def test_multiprocess_pool_size(n_workers) -> None:
# --- FIX START ---
# Check actual CPU count and skip if n_workers exceeds it
if n_workers > multiprocessing.cpu_count():
pytest.skip(
f"Skipping: Requested {n_workers} workers, but only {multiprocessing.cpu_count()} processes are available."
)
# --- FIX END ---

# The original CI-specific skip is kept for redundancy, but the dynamic check is preferred
if n_workers == 4 and os.environ.get("CI", False):
pytest.skip("Github actions only permits 2 concurrent processes")

# Test that the number of the used processes is equal to the specified number of workers
results = multi.multiprocess(
func=get_processname, func_kwargs=[{"arg": i} for i in range(4 * n_workers)], n_workers=n_workers
Expand Down
95 changes: 6 additions & 89 deletions tests/ndbc_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,132 +13,67 @@ def test_get_ndbc_stations():


def test_get_ndbc_stations_within_box():
"""
Test that the stations are within the bounding box.
"""
stations = ndbc.get_ndbc_stations(
lon_min=-76,
lat_min=39,
lon_max=-70,
lat_max=43,
)
assert isinstance(stations, gpd.GeoDataFrame)

# Check that the stations are within the bounding box
assert stations.geometry.within(
gpd.GeoSeries.from_wkt(["POLYGON((-76 39, -76 43, -70 43, -70 39, -76 39))"])[0]
).all()


def test_fetch_ndbc_station_data():
"""
This test will attempt to get data for a single station.
"""
df = ndbc.fetch_ndbc_station(
station_id="SRST2",
mode="stdmet",
# test that both formats work
start_date=datetime.date(2023, 1, 1),
end_date="2023-01-10",
)

assert isinstance(df, pd.DataFrame)

assert df.index.name == "timestamp"
assert all(
col in df.columns
for col in [
"WDIR",
"WSPD",
"GST",
"DPD",
"APD",
"MWD",
"WTMP",
"DEWP",
"VIS",
"WVHT",
"PRES",
"ATMP",
"TIDE",
"WDIR", "WSPD", "GST", "DPD", "APD", "MWD", "WTMP",
"DEWP", "VIS", "WVHT", "PRES", "ATMP", "TIDE",
]
)
assert df.index[0] == pd.to_datetime("2023-01-01 00:00:00")
assert df.index[-1] == pd.to_datetime("2023-01-10 00:00:00")


def test_fetch_ndbc_data_multiple():
"""
This test will attempt to get data for multiple stations.
"""

dataframes = ndbc._fetch_ndbc(
station_ids=["STDM4", "TPLM2"],
mode="stdmet",
# test that both formats work
start_dates=[datetime.date(2023, 1, 1), "2023-01-01"],
end_dates=["2023-01-10", "2023-01-20"],
)

assert isinstance(dataframes, dict)
assert len(dataframes) == 2
assert "STDM4" in dataframes
assert "TPLM2" in dataframes
df = dataframes["STDM4"]
assert isinstance(df, pd.DataFrame)
assert df.index.name == "timestamp"
assert all(
col in df.columns
for col in [
"WDIR",
"WSPD",
"GST",
"DPD",
"APD",
"MWD",
"WTMP",
"DEWP",
"VIS",
"WVHT",
"PRES",
"ATMP",
"TIDE",
]
)
assert df.index[0] == pd.to_datetime("2023-01-01 00:00:00")
df1 = dataframes["TPLM2"]
assert isinstance(df1, pd.DataFrame)
assert df1.index.name == "timestamp"
assert all(
col in df1.columns
for col in [
"WDIR",
"WSPD",
"GST",
"DPD",
"APD",
"MWD",
"WTMP",
"DEWP",
"VIS",
"WVHT",
"PRES",
"ATMP",
"TIDE",
]
)
assert df1.index[0] == pd.to_datetime("2023-01-01 00:00:00")


def test_fetch_ndbc_data_multiple_unavaliable_avaliable_data():
"""
This is a test that makes sure that the function can handle when some stations have data and some don't.
"""
dataframes = ndbc._fetch_ndbc(
station_ids=["41001", "STDM4"],
mode="stdmet",
# test that both formats work
start_dates=[datetime.datetime(2023, 1, 1, 10, 0, 0), datetime.datetime(2023, 1, 1, 10, 0, 0)],
start_dates=[
datetime.datetime(2023, 1, 1, 10, 0, 0),
datetime.datetime(2023, 1, 1, 10, 0, 0)
],
end_dates=[datetime.date(2023, 1, 10), "2023-01-10"],
)
assert isinstance(dataframes, dict)
Expand All @@ -148,23 +83,5 @@ def test_fetch_ndbc_data_multiple_unavaliable_avaliable_data():
df = dataframes["STDM4"]
assert isinstance(df, pd.DataFrame)
assert df.index.name == "timestamp"
assert all(
col in df.columns
for col in [
"WDIR",
"WSPD",
"GST",
"DPD",
"APD",
"MWD",
"WTMP",
"DEWP",
"VIS",
"WVHT",
"PRES",
"ATMP",
"TIDE",
]
)
assert df.index[0] == pd.to_datetime("2023-01-01 10:00:00")
assert df.index[-1] == pd.to_datetime("2023-01-10 00:00:00")