Skill: minshi-veyt/weather-loader (version 1.0.1)

--- name: weather-loader description: "Fetch historical or forecast weather by location or coordinates via Open-Meteo. No API key needed." license: MIT --- # Weather Data Fetcher Fetch daily weather (historical and forecast) by location name or coordinates via Open-Meteo. No API key needed. Scripts: `fetch_historical.py` (historical), `fetch_forecast.py` (forecast). ## When to invoke Use this skill when the user asks for: - Historical weather data for a location or coordinates - Weather forecast for the next days - Precipitation or evapotranspiration data for garden planning ## Location vs coordinates Provide **either**: - **Location**: positional or `--location` / `-l` (e.g. `"Oslo, Norway"`) — geocoded via Open-Meteo - **Coordinates**: `--lat` and `--lon` (both required) **Days**: `--days N` — history default 10 (max 30), forecast default 7 (max 15). ## `weather_fetch_historical` Run `scripts/fetch_historical.py`. **Observed** weather (Open-Meteo archive API). Default 10 days, max 30 (`--days N`). ```bash python scripts/fetch_historical.py "Oslo, Norway" python scripts/fetch_historical.py "Oslo, Norway" --days 20 --format json --kc 1.05 python scripts/fetch_historical.py --location "London, UK" --format csv python scripts/fetch_historical.py --lat 59.91 --lon 10.75 --days 30 ``` ## `weather_fetch_forecast` Run `scripts/fetch_forecast.py`. **Forecast** weather. Default 7 days, max 15 (`--days N`). ```bash python scripts/fetch_forecast.py "Oslo, Norway" python scripts/fetch_forecast.py "Paris, France" --days 15 --format json --kc 1.0 python scripts/fetch_forecast.py --lat 59.91 --lon 10.75 --days 14 --format csv ``` ## Output columns | Column | Description | |---|---| | `date` | YYYY-MM-DD | | `temp` | Mean daily temperature (°C) | | `precip` | Total daily precipitation (mm) | | `et0_fao` | FAO-56 reference evapotranspiration (mm/day), after `kc` | | `evaporation` | Estimated crop evaporation (mm) | | `agg_precip` | Cumulative precipitation | | `agg_evaporation` | Cumulative evapotranspiration | **`kc` (crop coefficient)**: multiplier applied to ET₀. Default 1.0. ## Technical notes - **Historical**: Open-Meteo archive API (observation/reanalysis); hourly → daily (temp = mean, precip/ET = sum). - **Forecast**: Open-Meteo forecast API; same daily aggregation. - **Geocoding**: location names resolved via Open-Meteo Geocoding API when not using `--lat`/`--lon`. ## Troubleshooting | Symptom | Fix | |---|---| | Location not found | Try a simpler string (e.g. `"Oslo"`) or use `--lat` and `--lon` | | Provide either location or both --lat and --lon | Give a location (positional or `--location`) or both `--lat` and `--lon` | | API failure | Check internet access; retry — Open-Meteo is free and usually reliable |

path: requirements.txt
content: # Weather Fetcher Skill Dependencies
# Install with: pip install -r requirements.txt

pandas>=2.0.0
requests>=2.31.0


path: README.md
content: # Weather Fetcher Skill

Fetch historical and forecast weather by location name or coordinates via [Open-Meteo](https://open-meteo.com/) (free, no API key).

## Quick Start

```bash
# From the skill directory
pip install -r requirements.txt

# By location (geocoded)
python scripts/fetch_historical.py "Oslo, Norway"
python scripts/fetch_forecast.py "Oslo, Norway"

# By coordinates
python scripts/fetch_historical.py --lat 59.91 --lon 10.75
python scripts/fetch_forecast.py --lat 59.91 --lon 10.75 --format csv
```

Output is printed to stdout (CSV or JSON). Redirect to save: `python scripts/fetch_historical.py "Oslo" > out.csv`.

## Location vs coordinates

Provide **either**:

- **Location**: positional or `--location` / `-l` (e.g. `"Oslo, Norway"`) — resolved via Open-Meteo Geocoding API
- **Coordinates**: `--lat` and `--lon` (both required)

## Options

| Option | Description |
|--------|-------------|
| `location` | Place name (positional) |
| `--location`, `-l` | Place name (alternative to positional) |
| `--lat` | Latitude (use with `--lon`) |
| `--lon` | Longitude (use with `--lat`) |
| `--kc` | Crop coefficient for ET₀ (default 1.0) |
| `--format`, `-f` | `csv` (default) or `json` |

## Features

- **Historical**: Last 10 days of **observed** weather (Open-Meteo archive API)
- **Forecast**: Next **7 days** of forecast weather
- Temperature, precipitation, evapotranspiration (ET₀), cumulative columns
- CSV or JSON output to stdout

## Output columns

| Column | Description |
|--------|-------------|
| `date` | YYYY-MM-DD |
| `temp` | Mean daily temperature (°C) |
| `precip` | Total daily precipitation (mm) |
| `et0_fao` | FAO-56 reference evapotranspiration (mm/day), after `kc` |
| `evaporation` | Estimated evaporation (mm) |
| `agg_precip` | Cumulative precipitation |
| `agg_evaporation` | Cumulative evapotranspiration |

## Structure

```
skills/weather-fetcher/
├── SKILL.md
├── README.md
├── requirements.txt
├── scripts/
│   ├── fetch_historical.py
│   ├── fetch_forecast.py
│   └── utils.py
└── tests/
    └── test_scripts.py
```

## Testing

```bash
pip install -r requirements.txt
python tests/test_scripts.py
```

Tests cover utils (get_lat_lon, calculate_evaporation, process_data), script runs by location, and script runs by `--lat` / `--lon` (live API).

## Documentation

See [SKILL.md](SKILL.md) for full skill documentation, troubleshooting, and technical notes.


path: scripts/fetch_forecast.py
content: #!/usr/bin/env python3
"""
Fetch Forecast Weather Data

Script to fetch weather forecast data for a given location.
Returns the next N days of weather predictions (default 7, max 15).
"""

import argparse
import sys
import json

# Import from local utils module
from utils import get_lat_lon, load_forecast, process_data, DEFAULT_FORECAST_DAYS, MAX_FORECAST_DAYS


def fetch_forecast_weather(lat: float, lon: float, display_name: str = None, days: int = None, kc: float = 1.0, output_format: str = 'csv'):
    """
    Fetch forecast weather data for coordinates.

    Args:
        lat: Latitude
        lon: Longitude
        display_name: Optional label for logs (e.g. from geocoding)
        kc: Crop coefficient for evapotranspiration (default: 1.0)
        output_format: Output format - 'csv' or 'json' (default: 'csv')

    Returns:
        DataFrame with forecast weather data or None if error
    """
    if display_name is None:
        display_name = f"({lat:.5f}, {lon:.5f})"
    print(f"Using coordinates: {display_name} (Lat: {lat:.5f}, Lon: {lon:.5f})", file=sys.stderr)

    # Load forecast data
    print("Fetching weather forecast data...", file=sys.stderr)
    data = load_forecast(lat, lon, days=days)
    
    if data is None or data.empty:
        print("Error: No forecast data available for this location", file=sys.stderr)
        return None
    
    # Process data with crop coefficient
    data = process_data("forecast", data, kc=kc)
    
    print(f"Retrieved {len(data)} days of forecast data", file=sys.stderr)
    
    return data


def main():
    """Main entry point for the script."""
    parser = argparse.ArgumentParser(
        description="Fetch weather forecast data for a location or coordinates",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python fetch_forecast.py "Oslo, Norway"
  python fetch_forecast.py --location "London, UK" --format json
  python fetch_forecast.py --lat 59.91 --lon 10.75 --days 15 --kc 1.05
        """
    )

    parser.add_argument(
        "location",
        nargs="?",
        type=str,
        help="Location name (e.g., 'Oslo, Norway')"
    )
    parser.add_argument(
        "--location", "-l",
        type=str,
        dest="location_opt",
        metavar="LOCATION",
        help="Location name (alternative to positional)"
    )
    parser.add_argument(
        "--lat",
        type=float,
        help="Latitude (use with --lon instead of location)"
    )
    parser.add_argument(
        "--lon",
        type=float,
        help="Longitude (use with --lat instead of location)"
    )
    parser.add_argument(
        "--days",
        type=int,
        default=DEFAULT_FORECAST_DAYS,
        metavar="N",
        help=f"Number of days of forecast (default: {DEFAULT_FORECAST_DAYS}, max: {MAX_FORECAST_DAYS})"
    )

    parser.add_argument(
        "--kc",
        type=float,
        default=1.0,
        help="Crop coefficient for evapotranspiration calculation (default: 1.0)"
    )
    
    parser.add_argument(
        "--format", "-f",
        type=str,
        choices=['csv', 'json'],
        default='csv',
        help="Output format: 'csv' or 'json' (default: csv)"
    )
    
    args = parser.parse_args()

    # Resolve lat/lon: either from --lat/--lon or by looking up location
    lat, lon, display_name = None, None, None
    if args.lat is not None or args.lon is not None:
        if args.lat is None or args.lon is None:
            parser.error("Both --lat and --lon must be given together")
        lat, lon = args.lat, args.lon
        display_name = f"({lat:.5f}, {lon:.5f})"
    else:
        location_str = args.location or args.location_opt
        if not location_str:
            parser.error("Provide either location (positional or --location) or both --lat and --lon")
        print(f"Looking up location: {location_str}", file=sys.stderr)
        lat, lon, display_name = get_lat_lon(location_str)
        if lat is None or lon is None:
            print(f"Error: Location not found - {location_str}", file=sys.stderr)
            sys.exit(1)

    # Validate crop coefficient and days
    if args.kc < 0 or args.kc > 2:
        print("Warning: Crop coefficient (Kc) is typically between 0 and 2", file=sys.stderr)
    if args.days < 1 or args.days > MAX_FORECAST_DAYS:
        parser.error(f"--days must be between 1 and {MAX_FORECAST_DAYS}")

    # Fetch data
    data = fetch_forecast_weather(lat, lon, display_name=display_name, days=args.days, kc=args.kc, output_format=args.format)
    
    if data is None:
        sys.exit(1)
    
    # Serialise once
    if args.format == 'json':
        data_dict = data.to_dict(orient='records')
        for record in data_dict:
            if 'date' in record:
                record['date'] = str(record['date'])
        output_str = json.dumps(data_dict, indent=2)
    else:
        output_str = data.to_csv(index=False)

    print(output_str)
    
    # Print summary statistics
    print("\n--- Summary Statistics ---", file=sys.stderr)
    print(f"Date range: {data['date'].min()} to {data['date'].max()}", file=sys.stderr)
    print(f"Average temperature: {data['temp'].mean():.1f}°C", file=sys.stderr)
    print(f"Total precipitation (forecast): {data['precip'].sum():.1f} mm", file=sys.stderr)
    print(f"Total evapotranspiration (forecast): {data['et0_fao'].sum():.1f} mm", file=sys.stderr)
    
    # Calculate water balance
    water_balance = data['precip'].sum() - data['et0_fao'].sum()
    if water_balance > 0:
        print(f"Forecasted water balance: +{water_balance:.1f} mm (surplus)", file=sys.stderr)
    else:
        print(f"Forecasted water balance: {water_balance:.1f} mm (deficit)", file=sys.stderr)
    
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/fetch_historical.py
content: #!/usr/bin/env python3
"""
Fetch Historical Weather Data

Script to fetch historical weather data for a given location.
Returns the last N days of actual weather observations (default 10, max 30).
"""

import argparse
import sys
import json

# Import from local utils module
from utils import get_lat_lon, load_history, process_data, DEFAULT_HISTORICAL_DAYS, MAX_HISTORICAL_DAYS


def fetch_historical_weather(lat: float, lon: float, display_name: str = None, days: int = None, kc: float = 1.0, output_format: str = 'csv'):
    """
    Fetch historical weather data for coordinates.

    Args:
        lat: Latitude
        lon: Longitude
        display_name: Optional label for logs (e.g. from geocoding)
        kc: Crop coefficient for evapotranspiration (default: 1.0)
        output_format: Output format - 'csv' or 'json' (default: 'csv')

    Returns:
        DataFrame with historical weather data or None if error
    """
    if display_name is None:
        display_name = f"({lat:.5f}, {lon:.5f})"
    print(f"Using coordinates: {display_name} (Lat: {lat:.5f}, Lon: {lon:.5f})", file=sys.stderr)

    # Load historical data
    print("Fetching historical weather data...", file=sys.stderr)
    data = load_history(lat, lon, days=days)
    
    if data is None or data.empty:
        print("Error: No historical data available for this location", file=sys.stderr)
        return None
    
    # Process data with crop coefficient
    data = process_data("historical", data, kc=kc)
    
    print(f"Retrieved {len(data)} days of historical data", file=sys.stderr)
    
    return data


def main():
    """Main entry point for the script."""
    parser = argparse.ArgumentParser(
        description="Fetch historical weather data for a location or coordinates",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python fetch_historical.py "Oslo, Norway"
  python fetch_historical.py --location "London, UK" --format json
  python fetch_historical.py --lat 59.91 --lon 10.75 --days 20 --kc 1.05
        """
    )

    parser.add_argument(
        "location",
        nargs="?",
        type=str,
        help="Location name (e.g., 'Oslo, Norway')"
    )
    parser.add_argument(
        "--location", "-l",
        type=str,
        dest="location_opt",
        metavar="LOCATION",
        help="Location name (alternative to positional)"
    )
    parser.add_argument(
        "--lat",
        type=float,
        help="Latitude (use with --lon instead of location)"
    )
    parser.add_argument(
        "--lon",
        type=float,
        help="Longitude (use with --lat instead of location)"
    )
    parser.add_argument(
        "--days",
        type=int,
        default=DEFAULT_HISTORICAL_DAYS,
        metavar="N",
        help=f"Number of days of history (default: {DEFAULT_HISTORICAL_DAYS}, max: {MAX_HISTORICAL_DAYS})"
    )

    parser.add_argument(
        "--kc",
        type=float,
        default=1.0,
        help="Crop coefficient for evapotranspiration calculation (default: 1.0)"
    )
    
    parser.add_argument(
        "--format", "-f",
        type=str,
        choices=['csv', 'json'],
        default='csv',
        help="Output format: 'csv' or 'json' (default: csv)"
    )
    
    args = parser.parse_args()

    # Resolve lat/lon: either from --lat/--lon or by looking up location
    lat, lon, display_name = None, None, None
    if args.lat is not None or args.lon is not None:
        if args.lat is None or args.lon is None:
            parser.error("Both --lat and --lon must be given together")
        lat, lon = args.lat, args.lon
        display_name = f"({lat:.5f}, {lon:.5f})"
    else:
        location_str = args.location or args.location_opt
        if not location_str:
            parser.error("Provide either location (positional or --location) or both --lat and --lon")
        print(f"Looking up location: {location_str}", file=sys.stderr)
        lat, lon, display_name = get_lat_lon(location_str)
        if lat is None or lon is None:
            print(f"Error: Location not found - {location_str}", file=sys.stderr)
            sys.exit(1)

    # Validate crop coefficient and days
    if args.kc < 0 or args.kc > 2:
        print("Warning: Crop coefficient (Kc) is typically between 0 and 2", file=sys.stderr)
    if args.days < 1 or args.days > MAX_HISTORICAL_DAYS:
        parser.error(f"--days must be between 1 and {MAX_HISTORICAL_DAYS}")

    # Fetch data
    data = fetch_historical_weather(lat, lon, display_name=display_name, days=args.days, kc=args.kc, output_format=args.format)
    
    if data is None:
        sys.exit(1)
    
    # Serialise once
    if args.format == 'json':
        data_dict = data.to_dict(orient='records')
        for record in data_dict:
            if 'date' in record:
                record['date'] = str(record['date'])
        output_str = json.dumps(data_dict, indent=2)
    else:
        output_str = data.to_csv(index=False)

    print(output_str)
    
    # Print summary statistics
    print("\n--- Summary Statistics ---", file=sys.stderr)
    print(f"Date range: {data['date'].min()} to {data['date'].max()}", file=sys.stderr)
    print(f"Average temperature: {data['temp'].mean():.1f}°C", file=sys.stderr)
    print(f"Total precipitation: {data['precip'].sum():.1f} mm", file=sys.stderr)
    print(f"Total evapotranspiration: {data['et0_fao'].sum():.1f} mm", file=sys.stderr)
    
    # Calculate water balance
    water_balance = data['precip'].sum() - data['et0_fao'].sum()
    if water_balance > 0:
        print(f"Water balance: +{water_balance:.1f} mm (surplus)", file=sys.stderr)
    else:
        print(f"Water balance: {water_balance:.1f} mm (deficit)", file=sys.stderr)
    
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/utils.py
content: #!/usr/bin/env python3
"""
Weather Utilities

Utility functions for fetching and processing weather data.
These functions are self-contained and don't depend on external modules.
"""

import datetime

import pandas as pd
import requests


# Configuration constants
DEFAULT_HISTORICAL_DAYS = 10
MAX_HISTORICAL_DAYS = 30
DEFAULT_FORECAST_DAYS = 7
MAX_FORECAST_DAYS = 15

FORECAST_ENDPOINT_METEO = (
    "https://api.open-meteo.com/v1/forecast?latitude={lat}"
    "&longitude={lon}"
    "&forecast_days={forecast_days}"
    "&hourly=temperature_2m,relative_humidity_2m,precipitation,"
    "wind_speed_10m,cloud_cover,et0_fao_evapotranspiration,vapour_pressure_deficit"
)
HISTORY_OBSERVATION_ENDPOINT = (
    "https://archive-api.open-meteo.com/v1/archive?latitude={lat}"
    "&longitude={lon}&start_date={start_date}&end_date={end_date}"
    "&hourly=temperature_2m,relative_humidity_2m,precipitation,"
    "wind_speed_10m,cloud_cover,et0_fao_evapotranspiration,vapour_pressure_deficit"
)
LATLON_ENDPOINT = "https://geocoding-api.open-meteo.com/v1/search"


def calculate_evaporation(
    temp, humidity, wind_speed, cloud_fraction, hour_resolution, area=1 * 1
):
    """
    Calculate evaporation based on weather conditions.
    
    Args:
        temp: Temperature (°C)
        humidity: Relative humidity (%)
        wind_speed: Wind speed (m/s)
        cloud_fraction: Cloud cover (%)
        hour_resolution: Hours between measurements
        area: Area in square meters (default: 1)
    
    Returns:
        Estimated evaporation (mm)
    """
    evaporation = (
        (0.5 + 0.05 * temp)
        * (1 - humidity / 100)
        * (1 + 0.1 * wind_speed)
        * (1 - cloud_fraction / 100)
    )
    if hour_resolution == 1:
        return evaporation * area / 24
    else:
        return evaporation * area / 4


def get_lat_lon(location):
    """
    Get latitude, longitude, and display name for a location.
    
    Args:
        location: Location name (e.g., "Oslo, Norway")
    
    Returns:
        Tuple of (latitude, longitude, display_name) or (None, None, None)
    """
    # Remove spaces and convert to lowercase
    location = location.strip().lower()
    params = {"name": location, "count": 1}
    response = requests.get(LATLON_ENDPOINT, params=params)
    
    if response.status_code == 200:
        data = response.json()
        if "results" in data and len(data["results"]) > 0:
            lat = float(data["results"][0]["latitude"])
            lon = float(data["results"][0]["longitude"])
            display_name = data["results"][0]["name"]
            if "admin1" in data["results"][0]:
                display_name += f', {data["results"][0]["admin1"]}'
            if "country" in data["results"][0]:
                display_name += f', {data["results"][0]["country"]}'
            
            return lat, lon, display_name
    
    return None, None, None


def load_history(lat, lon, days=None):
    """
    Load historical weather data for a location.

    Args:
        lat: Latitude
        lon: Longitude
        days: Number of days of history (default DEFAULT_HISTORICAL_DAYS, max MAX_HISTORICAL_DAYS)

    Returns:
        DataFrame with historical weather data or None
    """
    if days is None:
        days = DEFAULT_HISTORICAL_DAYS
    days = min(max(1, int(days)), MAX_HISTORICAL_DAYS)

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"
    }

    start_date = (
        datetime.datetime.now() - datetime.timedelta(days=days)
    ).strftime("%Y-%m-%d")
    end_date = (datetime.datetime.now() - datetime.timedelta(days=1)).strftime(
        "%Y-%m-%d"
    )
    
    # Fetch historical observation data (archive/reanalysis)
    url = HISTORY_OBSERVATION_ENDPOINT.format(
        lat=lat, lon=lon, start_date=start_date, end_date=end_date
    )
    response = requests.get(url, headers=headers)
    
    if response.status_code == 200:
        try:
            obs = response.json()
            if "hourly" not in obs:
                print("No hourly data found in observation response.")
                return None
            data = pd.DataFrame(obs["hourly"])
        except Exception as e:
            print(f"Error parsing historical observation data: {e}")
            return None
    else:
        return None
    
    if len(data) > 0:
        data = data.dropna()
        
        data.columns = [
            "time",
            "temp",
            "humidity",
            "precip",
            "wind_speed",
            "cloud_fraction",
            "et0_fao",
            "vapour",
        ]
        
        data["time"] = pd.to_datetime(data["time"])
        # Convert wind speed from km/h to m/s
        data["wind_speed"] = data["wind_speed"] / 3.6
        data["date"] = data["time"].dt.date
        data["hour_resolution"] = data["time"].diff().dt.total_seconds() / 3600
        data = data.dropna()
        
        # Calculate evaporation
        data["evaporation"] = data.apply(
            lambda row: calculate_evaporation(
                row["temp"],
                row["humidity"],
                row["wind_speed"],
                row["cloud_fraction"],
                row["hour_resolution"],
            ),
            axis=1,
        )
        
        # Aggregate to daily data
        data = (
            data.groupby("date")
            .agg(
                {
                    "temp": "mean",
                    "precip": "sum",
                    "et0_fao": "sum",
                    "evaporation": "sum",
                }
            )
            .reset_index()
        )
        
        return data
    
    return None


def load_forecast(lat, lon, days=None):
    """
    Load forecast weather data for a location.

    Args:
        lat: Latitude
        lon: Longitude
        days: Number of days of forecast (default DEFAULT_FORECAST_DAYS, max MAX_FORECAST_DAYS)

    Returns:
        DataFrame with forecast weather data or None
    """
    if days is None:
        days = DEFAULT_FORECAST_DAYS
    days = min(max(1, int(days)), MAX_FORECAST_DAYS)

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"
    }

    url = FORECAST_ENDPOINT_METEO.format(lat=lat, lon=lon, forecast_days=days)
    response = requests.get(url, headers=headers)
    
    if response.status_code == 200:
        try:
            data = response.json()
            if "hourly" not in data:
                print("No hourly data found in response.")
                return None
            
            data = pd.DataFrame(data["hourly"])
            data.columns = [
                "time",
                "temp",
                "humidity",
                "precip",
                "wind_speed",
                "cloud_fraction",
                "et0_fao",
                "vapour",
            ]
            
            data["time"] = pd.to_datetime(data["time"])
            # Convert wind speed from km/h to m/s
            data["wind_speed"] = data["wind_speed"] / 3.6
            data["date"] = data["time"].dt.date
            data["hour_resolution"] = data["time"].diff().dt.total_seconds() / 3600
            data = data.dropna()
            
            # Calculate evaporation
            data["evaporation"] = data.apply(
                lambda row: calculate_evaporation(
                    row["temp"],
                    row["humidity"],
                    row["wind_speed"],
                    row["cloud_fraction"],
                    row["hour_resolution"],
                ),
                axis=1,
            )
            
            # Aggregate to daily data
            data = (
                data.groupby("date")
                .agg(
                    {
                        "temp": "mean",
                        "precip": "sum",
                        "et0_fao": "sum",
                        "evaporation": "sum",
                    }
                )
                .reset_index()
            )
            
            return data
        except Exception as e:
            print(f"Error parsing forecast data: {e}")
    else:
        print(f"Error fetching forecast data: {response.status_code}")
    
    return None


def process_data(data_type, data, kc=1.0):
    """
    Process weather data by applying crop coefficient and calculating cumulative values.
    
    Args:
        data_type: Type of data - "historical" or "forecast"
        data: DataFrame with weather data
        kc: Crop coefficient (default: 1.0)
    
    Returns:
        Processed DataFrame or None
    """
    if data is None or data.empty:
        return None
    
    # Apply crop coefficient to evapotranspiration
    data["et0_fao"] = data["et0_fao"] * kc
    
    # Calculate cumulative values
    data["agg_precip"] = data["precip"].cumsum()
    data["agg_evaporation"] = data["et0_fao"].cumsum()
    
    return data


if __name__ == "__main__":
    # Test the functions
    print("Testing weather utilities...")
    
    # Test location lookup
    lat, lon, display_name = get_lat_lon("Oslo, Norway")
    if lat and lon:
        print(f"✓ Location: {display_name} ({lat:.2f}, {lon:.2f})")
    else:
        print("✗ Failed to get location")
    
    print("\nUtilities are ready to use!")


path: tests/test_scripts.py
content: #!/usr/bin/env python3
"""
Weather-fetcher test suite.

Tests:
  1. Utils functions  — get_lat_lon, calculate_evaporation, process_data
  2. Script execution (location) — fetch_historical.py, fetch_forecast.py with location
  3. Script execution (--lat/--lon) — same scripts with --lat and --lon (live API)

Usage:
    python tests/test_scripts.py
"""

import sys
import subprocess
import tempfile
from pathlib import Path

_SKILL_DIR = Path(__file__).resolve().parent.parent
_SCRIPTS_DIR = _SKILL_DIR / "scripts"
sys.path.insert(0, str(_SCRIPTS_DIR))

PASS = "✅"
FAIL = "❌"


def section(title: str) -> None:
    print(f"\n{'─' * 50}")
    print(f"  {title}")
    print(f"{'─' * 50}")


# ---------------------------------------------------------------------------
# 1. Utils functions
# ---------------------------------------------------------------------------

def test_utils() -> bool:
    section("Utils functions")
    try:
        import utils
    except ImportError as e:
        print(f"{FAIL} Could not import utils: {e}")
        return False

    ok = True

    # get_lat_lon (requires network; skip with clear message if unreachable)
    try:
        lat, lon, name = utils.get_lat_lon("Oslo, Norway")
        if lat is None or lon is None:
            print(f"  ⚠ get_lat_lon  →  skipped (location lookup failed, check network/proxy)")
        else:
            print(f"{PASS} get_lat_lon  →  {name} ({lat:.2f}, {lon:.2f})")
    except Exception as e:
        err = str(e).split("\n")[0]
        if "proxy" in err.lower() or "connection" in err.lower() or "403" in err or "Max retries" in err:
            print(f"  ⚠ get_lat_lon  →  skipped (network unreachable: {err[:60]}...)")
        else:
            print(f"{FAIL} get_lat_lon: {e}")
            ok = False

    # calculate_evaporation
    try:
        evap = utils.calculate_evaporation(
            temp=20.0, humidity=60.0, wind_speed=5.0,
            cloud_fraction=50.0, hour_resolution=1.0
        )
        assert evap is not None and evap >= 0, f"invalid value: {evap}"
        print(f"{PASS} calculate_evaporation  →  {evap:.4f} mm")
    except Exception as e:
        print(f"{FAIL} calculate_evaporation: {e}")
        ok = False

    # process_data
    try:
        import pandas as pd
        sample = pd.DataFrame({
            'date': pd.date_range('2026-01-01', periods=3),
            'temp': [10, 12, 11],
            'precip': [2.5, 0.0, 1.0],
            'et0_fao': [1.5, 1.8, 1.6],
            'evaporation': [1.4, 1.7, 1.5],
        })
        result = utils.process_data("historical", sample, kc=1.0)
        assert result is not None and not result.empty
        agg_cols = [c for c in result.columns if c.startswith('agg_')]
        print(f"{PASS} process_data  →  added columns: {', '.join(agg_cols)}")
    except Exception as e:
        print(f"{FAIL} process_data: {e}")
        ok = False

    return ok


# ---------------------------------------------------------------------------
# 2. Script execution (live API)
# ---------------------------------------------------------------------------

def _run_script(script_name: str, args: list) -> bool:
    script_path = _SCRIPTS_DIR / script_name
    with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as tmp:
        tmp_path = Path(tmp.name)

    try:
        cmd = [sys.executable, str(script_path)] + args
        result = subprocess.run(
            cmd,
            capture_output=True, text=True, timeout=30,
        )
        if result.returncode != 0:
            print(f"{FAIL} {script_name} failed:\n{result.stderr.strip()}")
            return False
        if not result.stdout.strip():
            print(f"{FAIL} {script_name} produced no output")
            return False
        tmp_path.write_text(result.stdout)
        import pandas as pd
        data = pd.read_csv(tmp_path)
        print(f"{PASS} {script_name}  →  {len(data)} rows, columns: {', '.join(data.columns)}")
        return True
    except subprocess.TimeoutExpired:
        print(f"{FAIL} {script_name} timed out (>30 s)")
        return False
    except Exception as e:
        print(f"{FAIL} {script_name}: {e}")
        return False
    finally:
        tmp_path.unlink(missing_ok=True)


def test_scripts() -> bool:
    section("Script execution (live API — location)")
    results = [
        _run_script(s, ["Oslo, Norway", "--format", "csv"])
        for s in ("fetch_historical.py", "fetch_forecast.py")
    ]
    return all(results)


def test_scripts_lat_lon() -> bool:
    section("Script execution (live API — --lat / --lon)")
    # Oslo coordinates
    lat, lon = "59.91", "10.75"
    results = [
        _run_script(s, ["--lat", lat, "--lon", lon, "--format", "csv"])
        for s in ("fetch_historical.py", "fetch_forecast.py")
    ]
    return all(results)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
    print("\n  WEATHER-FETCHER TEST SUITE")

    results = {
        "utils":        test_utils(),
        "scripts":      test_scripts(),
        "scripts_latlon": test_scripts_lat_lon(),
    }

    section("Summary")
    all_ok = True
    for name, ok in results.items():
        icon = PASS if ok else FAIL
        print(f"  {icon}  {name:<10} {'PASS' if ok else 'FAIL'}")
        if not ok:
            all_ok = False
    print()
    return 0 if all_ok else 1


if __name__ == "__main__":
    sys.exit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)