diff --git a/.claude/skills/meteo/SKILL.md b/.claude/skills/meteo/SKILL.md new file mode 100644 index 0000000..117af3c --- /dev/null +++ b/.claude/skills/meteo/SKILL.md @@ -0,0 +1,38 @@ +--- +name: meteo +description: Access weather and climate data for thousands of weather stations and any place worldwide. +--- + +# Meteo + +## Usage + +If the `meteo` command is not found, install the tool using: + +```bash +uv tool install meteostat-cli +``` + +Alternatively, you can use `uvx`: + +```bash +uvx --from meteostat-cli meteo +``` + +## Commands + +Run `meteo --help` to see a list of all available commands and options. + +## Gotchas + +- `meteo nearby` requires numeric `LAT,LON` argument — city names are not accepted. Use coordinates directly. +- When looking for stations that match the elevation of a place, use `meteo nearby` without a radius and filter results by elevation. +- Pass multiple station IDs in one call instead of looping: `meteo daily D1424 EDEV0 10635 --start 2025 --end 2025` +- Fetch only the columns you need with `--parameters` to eliminate post-processing: `--parameters tmax` or `--parameters tmax,prcp` +- Aggregate all rows with `--agg` (Pandas `.agg()`): `--agg max`, `--agg mean`, `--agg sum` +- `--start` and `--end` accept YYYY-MM-DD, YYYY-MM, YYYY +- Machine-readable output for piping: `--format json` or `--format csv` + +## Additional resources + +- For usage examples, see [examples.md](examples.md) \ No newline at end of file diff --git a/.claude/skills/meteo/examples.md b/.claude/skills/meteo/examples.md new file mode 100644 index 0000000..88b66e8 --- /dev/null +++ b/.claude/skills/meteo/examples.md @@ -0,0 +1,72 @@ +# Examples + +## Fetch weather data + +```bash +meteo daily 10637 # Station ID for Frankfurt/Main +meteo hourly 10637 --start 2024-01-01 --end 2024-01-31 +meteo daily 50.1109,8.6821 --start 2024-01-01 --end 2024-12-31 # Geo coordinates (interpolated) +meteo monthly 10637 --start 2020 --end 2024 --parameters temp,tmin,tmax,prcp +meteo normals 10637 +meteo hourly 10637 10635 --start 2024-06-01 --end 2024-06-30 --timezone Europe/Berlin +meteo daily 10637 10635 --start 2025-01-01 --end 2025-12-31 --agg max # Custom aggregation (Pandas `.agg()`) across multiple stations +``` + +## Export data + +```bash +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --output data.csv +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --output data.json +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --output data.xlsx +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --output data.parquet +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --output chart.png +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --format csv --no-header +``` + +## Station discovery + +```bash +meteo station 10637 # Metadata for a specific station +meteo station --country DE # All stations in Germany +meteo station --country DE --state HE +meteo station --name "Frankfurt" # Search by name (case-insensitive, partial match) +meteo station --bbox "8.0,50.0,10.0,51.0" +meteo station --sql "SELECT * FROM stations WHERE country = 'DE' AND elevation > 500" +meteo nearby 50.1109,8.6821 --limit 10 --radius 20000 +``` + +## Data quality & filtering + +```bash +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --with-sources # Include source provider columns +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --no-models # Exclude model data +meteo daily 10637 10635 --start 2024-01-01 --end 2024-12-31 --agg mean +meteo daily 10637 --no-cache # Disable cache for the latest data +meteo daily 10637 --start 2024-01-01 --end 2024-12-31 --all # Show all rows without truncation +``` + +## Finding the hottest/coldest day + +```bash +# Peak tmax for a station over a full year — single command, no awk needed +meteo daily D1424 --start 2025-01-01 --end 2025-12-31 --parameters tmax --agg max -f csv +# To also get the date of the peak, filter with sort after fetching tmax only +meteo daily D1424 --start 2025-01-01 --end 2025-12-31 --parameters tmax -f csv \ + | awk -F',' 'NR>1' | sort -t',' -k3 -rn | head -1 +``` + +## Inventory + +```bash +meteo inventory 10637 +meteo inventory 10637 --granularity daily +meteo inventory 10637 --parameters temp,tmin,tmax +``` + +## Configuration + +```bash +meteo config --list +meteo config cache_enable false +meteo config interpolation_radius 50000 +``` diff --git a/meteo/cli.py b/meteo/cli.py index ec0ed8c..6db26fc 100644 --- a/meteo/cli.py +++ b/meteo/cli.py @@ -48,7 +48,6 @@ def _register_commands() -> None: app.command("config")(config_cmd) app.command("station")(station_cmd) - app.command("stations")(station_cmd) app.command("s", hidden=True)(station_cmd) app.command("nearby")(nearby_cmd) app.command("inventory")(inventory_cmd) diff --git a/meteo/commands/nearby.py b/meteo/commands/nearby.py index 5c415e5..366d71f 100644 --- a/meteo/commands/nearby.py +++ b/meteo/commands/nearby.py @@ -6,10 +6,11 @@ def nearby_cmd( - lat: float = typer.Argument(..., help="Latitude."), - lon: float = typer.Argument(..., help="Longitude."), + coords: str = typer.Argument(..., help="Coordinates as lat,lon (e.g. 48.1,11.6)."), limit: int = typer.Option(5, "--limit", "-l", help="Maximum number of stations."), - radius: int = typer.Option(5000, "--radius", "-r", help="Search radius in meters."), + radius: int | None = typer.Option( + None, "--radius", "-r", help="Search radius in meters." + ), fmt: str | None = typer.Option( None, "--format", "-f", help="Output format (csv, json, xlsx, parquet)." ), @@ -24,17 +25,25 @@ def nearby_cmd( """Find weather stations near a location.""" import meteostat as ms - if not (-90 <= lat <= 90): - typer.echo(f"Error: Latitude must be between -90 and 90, got {lat}.", err=True) - raise typer.Exit(2) - if not (-180 <= lon <= 180): + parts = coords.split(",", 1) + if len(parts) != 2: typer.echo( - f"Error: Longitude must be between -180 and 180, got {lon}.", err=True + "Error: Coordinates must be in lat,lon format (e.g. 48.1,11.6).", err=True ) raise typer.Exit(2) + try: + lat = float(parts[0]) + lon = float(parts[1]) + except ValueError: + typer.echo(f"Error: Invalid coordinates: {coords}", err=True) + raise typer.Exit(2) from None + if not (-90 <= lat <= 90): + raise typer.BadParameter(f"Latitude must be between -90 and 90, got {lat}") + if not (-180 <= lon <= 180): + raise typer.BadParameter(f"Longitude must be between -180 and 180, got {lon}") point = ms.Point(lat, lon) - df = ms.stations.nearby(point, radius=radius, limit=limit) + df = ms.stations.nearby(point, radius=radius or 40_075_000, limit=limit) actual_fmt = detect_format(fmt, output) output_df(df, actual_fmt, output, no_header, show_all=show_all) diff --git a/meteo/config.py b/meteo/config.py index e60b4b6..2e57420 100644 --- a/meteo/config.py +++ b/meteo/config.py @@ -14,6 +14,7 @@ # CLI-specific config keys (not part of Meteostat library config) CLI_CONFIG_KEYS = { "interpolation_radius": 25000, + "interpolation_station_count": 10, "humanize": True, } diff --git a/meteo/utils.py b/meteo/utils.py index d1c5d97..8809165 100644 --- a/meteo/utils.py +++ b/meteo/utils.py @@ -121,6 +121,15 @@ def get_interpolation_radius() -> int: return CLI_CONFIG_KEYS["interpolation_radius"] +def get_interpolation_station_count() -> int: + """Get the configured station count limit for coordinate-based queries.""" + data = load_config() + key = "interpolation_station_count" + if key in data: + return int(data[key]) + return CLI_CONFIG_KEYS["interpolation_station_count"] + + def detect_format(fmt: str | None, output: str | None) -> str: """Detect the output format from explicit flag or file extension.""" if fmt is not None: @@ -362,7 +371,9 @@ def fetch_timeseries( else: point = ms.Point(resolved[1], resolved[2]) radius = get_interpolation_radius() - nearby = ms.stations.nearby(point, radius=radius) + nearby = ms.stations.nearby( + point, radius=radius, limit=get_interpolation_station_count() + ) if nearby.empty: typer.echo( "No weather stations found within the specified radius.", @@ -397,7 +408,9 @@ def fetch_timeseries( else: point = ms.Point(resolved[1], resolved[2]) radius = get_interpolation_radius() - nearby = ms.stations.nearby(point, radius=radius) + nearby = ms.stations.nearby( + point, radius=radius, limit=get_interpolation_station_count() + ) if nearby.empty: typer.echo( "No weather stations found within the specified radius.", @@ -434,7 +447,9 @@ def fetch_timeseries( else: point = ms.Point(resolved[1], resolved[2]) radius = get_interpolation_radius() - nearby = ms.stations.nearby(point, radius=radius) + nearby = ms.stations.nearby( + point, radius=radius, limit=get_interpolation_station_count() + ) if nearby.empty: typer.echo( "No weather stations found within the specified radius.", diff --git a/tests/test_cli.py b/tests/test_cli.py index a79aa70..b6e6b5e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,12 +67,6 @@ def test_command_help_config(invoke): assert result.exit_code == 0 -def test_command_help_stations(invoke): - """Test that 'stations --help' works.""" - result = invoke("stations", "--help") - assert result.exit_code == 0 - - def test_command_help_nearby(invoke): """Test that 'nearby --help' works.""" result = invoke("nearby", "--help") diff --git a/tests/test_commands.py b/tests/test_commands.py index b526f36..b18e804 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -86,7 +86,7 @@ def test_nearby_basic(self, invoke): with patch("meteostat.stations") as mock_stations: mock_stations.nearby.return_value = df with patch("meteostat.Point"): - result = invoke("nearby", "50.1109", "8.6821") + result = invoke("nearby", "50.1109,8.6821") assert result.exit_code == 0 assert "Frankfurt" in result.output @@ -259,7 +259,7 @@ def test_nearby_all_flag(self, invoke): with patch("meteostat.stations") as mock_stations: mock_stations.nearby.return_value = df with patch("meteostat.Point"): - result = invoke("nearby", "50.1109", "8.6821", "--all") + result = invoke("nearby", "50.1109,8.6821", "--all") assert result.exit_code == 0 def test_hourly_all_flag(self, invoke): @@ -311,7 +311,6 @@ def test_all_flag_in_help(self, invoke): """--all flag appears in help for all data commands.""" for cmd in [ "station", - "stations", "nearby", "inventory", "hourly", @@ -544,7 +543,7 @@ def test_nearby_no_results(self, invoke): """Nearby command exits 1 when no stations found.""" with patch("meteostat.stations") as ms, patch("meteostat.Point"): ms.nearby.return_value = pd.DataFrame() - result = invoke("nearby", "50.1109", "8.6821") + result = invoke("nearby", "50.1109,8.6821") assert result.exit_code == 1 assert "No data" in result.output @@ -556,19 +555,19 @@ def test_nearby_csv_format(self, invoke): ) with patch("meteostat.stations") as ms, patch("meteostat.Point"): ms.nearby.return_value = df - result = invoke("nearby", "50.1109", "8.6821", "--format", "csv") + result = invoke("nearby", "50.1109,8.6821", "--format", "csv") assert result.exit_code == 0 assert "name" in result.output def test_nearby_invalid_latitude(self, invoke): """Nearby command exits 2 when latitude is out of range.""" - result = invoke("nearby", "200", "8.6821") + result = invoke("nearby", "200,8.6821") assert result.exit_code == 2 assert "Latitude" in result.output def test_nearby_invalid_longitude(self, invoke): """Nearby command exits 2 when longitude is out of range.""" - result = invoke("nearby", "50.1109", "200") + result = invoke("nearby", "50.1109,200") assert result.exit_code == 2 assert "Longitude" in result.output