Skill: dgorissen/stormglass (version 1.0.2)

--- name: stormglass-surf-skill description: Fetch surf-relevant ocean conditions from Stormglass by spot name or coordinates, including current snapshot and 1-3 day forecast windows with tides, gusts, and water temperature. Use when users ask for surf reports, wave or swell conditions, tide timing, wind, gusts, or water temperature for a beach or surf spot. version: 1.0.2 metadata: openclaw: requires: env: - STORMGLASS_API_KEY bins: - python3 primaryEnv: STORMGLASS_API_KEY homepage: https://github.com/dgorissen/stormglass-skill --- # Stormglass Surf Skill ## Purpose Produce machine-readable surf condition data for cron-driven agent pipelines. This skill resolves a surf spot name with Google Geocoding (or uses direct coordinates), queries Stormglass, and returns a stable JSON payload for downstream interpretation/rendering. ## Inputs Provide exactly one location mode: - `--location "Spot name"` (optional country/region in string), or - `--lat <float> --lon <float>` Optional controls: - `--horizon now|24h|48h|72h` (default `72h`) - `--output json|pretty` (default `json`, recommended for automation) - `--source <comma-separated provider list>` - `--mock` (offline deterministic data; useful for tests) ## Required Environment Variables - `STORMGLASS_API_KEY` for Stormglass requests - `GOOGLE_GEOCODING_API_KEY` optional for `--location` (if absent, script falls back to OpenStreetMap Nominatim) In `--mock` mode, no API keys are required. ### Credential Matrix | Mode | `STORMGLASS_API_KEY` | `GOOGLE_GEOCODING_API_KEY` | |---|---|---| | `--mock` | not required | not required | | live `--lat/--lon` | required | not required | | live `--location` with Google | required | optional (preferred when set) | | live `--location` with OSM fallback | required | not required | Primary credential is `STORMGLASS_API_KEY`. ## Execution Commands JSON output for cron: ```bash python scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --output json ``` Direct coordinates: ```bash python scripts/surf_report.py --lat 50.735 --lon -1.705 --horizon 24h --output json ``` Offline test run: ```bash python scripts/surf_report.py --location "Highcliffe Beach" --horizon now --mock --output json ``` ## Output Contract (JSON-first) Top-level keys are stable: - `meta`: request metadata, timestamps, input mode, optional warnings - `location`: resolved place details and coordinates - `now`: instantaneous surf-relevant metrics - `forecast`: horizon summaries and best windows - `tides`: tide extremes and inferred current tide trend Expected metric coverage (null if unavailable): - `waveHeightM` - `swellHeightM` - `swellPeriodS` - `swellDirectionDeg` - `windSpeedMps` - `windDirectionDeg` - `windGustMps` - `waterTemperatureC` ## Exit Codes - `0`: success - `2`: invalid CLI usage/arguments - `3`: missing API keys/configuration - `4`: external API failure (geocoding/Stormglass) - `5`: response parsing/normalization failure ## Agent Handoff Rules - Prefer `--output json` for downstream agents. - Treat null metrics as "not provided by source", not zero. - Read field-level details in `reference.md`. - Use `examples.md` for prompt and command patterns. - Use `scripts/test_surf_report.py` before cron rollout. - Optional: use `scripts/normalize_surf_data.py` to enforce strict schema defaults before rendering.

path: reference.md
content: # Stormglass Surf Skill Reference

## External APIs

- Stormglass docs: [https://docs.stormglass.io/#/](https://docs.stormglass.io/#/)
- Weather point endpoint: `GET https://api.stormglass.io/v2/weather/point`
- Tide extremes endpoint (preferred): `GET https://api.stormglass.io/v2/tide/extremes/point`
- Tide extremes fallback: `GET https://api.stormglass.io/v2/tide/extremes`
- Google geocoding endpoint: `GET https://maps.googleapis.com/maps/api/geocode/json`
- OpenStreetMap fallback geocoding endpoint: `GET https://nominatim.openstreetmap.org/search`

## Authentication

- Stormglass: send `Authorization: <STORMGLASS_API_KEY>`
- Google Geocoding: query parameter `key=<GOOGLE_GEOCODING_API_KEY>`

## Geocoding Flow (`--location`)

1. Request:
   - If `GOOGLE_GEOCODING_API_KEY` is set, use Google:
     - `address=<user location string>`
     - `key=<google key>`
   - Else fallback to OpenStreetMap Nominatim:
     - `q=<user location string>`
     - `format=jsonv2`
     - `limit=5`
2. Validate response and parse top match.
3. Capture:
   - `formatted_address`
   - `geometry.location.lat`
   - `geometry.location.lng`
   - `place_id` (if present)
   - For OSM fallback, use:
     - `display_name`
     - `lat`
     - `lon`
4. If multiple candidates exist, return warning in `meta.warnings`.

## Stormglass Weather Request

Query params:

- `lat`, `lng`
- `params=waveHeight,swellHeight,swellPeriod,swellDirection,windSpeed,windDirection,gust,waterTemperature`
- `start=<unix-utc-seconds>`
- `end=<unix-utc-seconds>`
- optional `source=<provider1,provider2>`

Response handling:

- Read `hours[]`.
- For each metric, values may be:
  - a number, or
  - an object of per-source values.
- If per-source object, choose first non-null value by precedence:
  - user-selected source order (if provided)
  - else default order: `sg`, `icon`, `gfs`, `ecmwf`, `dwd`, `noaa`, then first available key

## Stormglass Tide Request

Query params:

- `lat`, `lng`
- `start=<YYYY-MM-DD>`
- `end=<YYYY-MM-DD>`

Response handling:

- Read `data[]` from tide endpoint.
- Normalize each extreme into:
  - `time` (ISO UTC)
  - `height` (meters, nullable)
  - `type` (`high` or `low`)
- If `/v2/tide/extremes/point` fails with 404/400, retry `/v2/tide/extremes`.

## JSON Output Schema (Stable)

```json
{
  "meta": {
    "generatedAt": "2026-02-22T12:00:00Z",
    "horizon": "72h",
    "inputMode": "location|coordinates",
    "sourcesRequested": ["sg"],
    "warnings": []
  },
  "location": {
    "query": "Highcliffe Beach",
    "resolvedName": "Highcliffe, Christchurch, UK",
    "lat": 50.735,
    "lon": -1.705,
    "googlePlaceId": "..."
  },
  "now": {
    "time": "2026-02-22T12:00:00Z",
    "waveHeightM": 1.2,
    "swellHeightM": 0.8,
    "swellPeriodS": 9.4,
    "swellDirectionDeg": 245,
    "windSpeedMps": 5.1,
    "windDirectionDeg": 215,
    "windGustMps": 7.8,
    "waterTemperatureC": 10.9
  },
  "forecast": {
    "windows": {
      "24h": { "start": "...", "end": "...", "bestHours": [] },
      "48h": { "start": "...", "end": "...", "bestHours": [] },
      "72h": { "start": "...", "end": "...", "bestHours": [] }
    }
  },
  "tides": {
    "trendNow": "rising|falling|unknown",
    "extremes": []
  }
}
```

## Null / Missing Data Rules

- If upstream omits a metric, emit `null` for that field.
- Do not substitute `0` for missing values.
- Keep key names stable even when values are null.

## Cron and Downstream Agent Notes

- Use JSON mode in cron (`--output json`) and parse stdout only.
- Treat non-zero exit code as failure; parse stderr for diagnostics.
- Persist raw JSON for observability and replay.
- Optional post-processing: pipe through `scripts/normalize_surf_data.py` to enforce stable null/field defaults.


path: examples.md
content: # Stormglass Surf Skill Examples

## Example 1: Spot Name, 72h Horizon (JSON)

Command:

```bash
python scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --output json
```

What downstream agent should do:

- Parse JSON from stdout.
- Summarize current conditions.
- Highlight best surf windows in next 1-3 days.
- Mention tide timing around chosen windows.

## Example 2: Direct Coordinates, 24h Horizon

Command:

```bash
python scripts/surf_report.py --lat 50.735 --lon -1.705 --horizon 24h --output json
```

Use when:

- Coordinates are already known.
- You want to skip geocoding latency and ambiguity.

## Example 3: Ambiguous Location Input

Command:

```bash
python scripts/surf_report.py --location "Long Beach" --horizon 48h --output json
```

Expected behavior:

- Script returns a resolved top match in `location`.
- If ambiguity is detected, script includes a warning in `meta.warnings`.
- Downstream agent can ask user to confirm region/country.

## Example 4: Pretty Output for Manual Inspection

Command:

```bash
python scripts/surf_report.py --location "Highcliffe Beach" --horizon now --output pretty
```

Use when:

- Human operator is manually inspecting output in terminal.

## Example 5: Offline Mock Run (No API Keys Needed)

Command:

```bash
python scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --mock --output json
```

Use when:

- Validating cron wiring, parser behavior, and agent formatting logic without external API calls.

## Example 6: Cron Integration

Crontab entry:

```cron
*/30 * * * * cd /home/dgorissen/code/stormglass-skill && python scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --output json >> logs/surf_report.jsonl 2>> logs/surf_report.err
```

Operational notes:

- Ensure log directory exists.
- Rotate logs externally.
- Treat non-zero exit code as fetch failure; do not trust partial stdout.

## Example 7: Downstream Agent Prompt Pattern

Prompt:

```text
Interpret this surf JSON and produce a concise user summary with:
1) conditions now, 2) best 1-3 windows in next 72h, 3) tide timing, 4) confidence notes for null fields.
```

## Example 8: Normalization Pipeline (Optional)

Command:

```bash
python scripts/surf_report.py --lat 50.735 --lon -1.705 --horizon 72h --output json | python scripts/normalize_surf_data.py
```

Use when:

- You need strict JSON field consistency before another agent consumes the payload.


path: README.md
content: # Stormglass Surf Skill

An OpenClaw-friendly surf data skill that fetches current and 1-3 day ocean forecasts from Stormglass, including:

- wave height
- swell height/period/direction
- wind speed/direction
- wind gusts
- water temperature
- high/low tide extremes

It supports querying by:

- surf spot name (`--location`, with geocoding), or
- direct coordinates (`--lat` + `--lon`).

## Project Layout

- `SKILL.md` - skill contract and agent handoff guidance
- `scripts/surf_report.py` - main CLI
- `scripts/test_surf_report.py` - CLI test runner
- `scripts/normalize_surf_data.py` - optional schema normalizer
- `examples.md` - practical command/pipeline examples
- `reference.md` - API mapping, schema, and behavior details

## Requirements

- Python 3.10+
- Stormglass API key for non-mock runs
- Google Geocoding key optional (if absent, location mode falls back to OpenStreetMap Nominatim)

## Environment Variables

- `STORMGLASS_API_KEY` - required for live Stormglass requests
- `GOOGLE_GEOCODING_API_KEY` - optional; used when present for `--location`

### Credential Matrix

| Mode | `STORMGLASS_API_KEY` | `GOOGLE_GEOCODING_API_KEY` |
|---|---|---|
| `--mock` (offline tests) | not required | not required |
| live with `--lat/--lon` | required | not required |
| live with `--location` + Google geocoder | required | optional (preferred when set) |
| live with `--location` + OSM fallback | required | not required |

Primary credential for this skill is `STORMGLASS_API_KEY`.

## Quick Start

From repository root:

```bash
# 1) Offline smoke test (no keys required)
python3 scripts/surf_report.py --location "Highcliffe Beach" --horizon now --mock --output pretty

# 2) Live request by coordinates (Stormglass key required)
export STORMGLASS_API_KEY="..."
python3 scripts/surf_report.py --lat 50.735 --lon -1.705 --horizon 24h --output json

# 3) Live request by location (Google optional; OSM fallback if missing)
python3 scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --output json
```

## CLI Usage

```bash
python3 scripts/surf_report.py \
  (--location "Spot Name" | --lat <LAT> --lon <LON>) \
  [--horizon now|24h|48h|72h] \
  [--output json|pretty] \
  [--source "sg,noaa"] \
  [--timeout 20] \
  [--mock]
```

### Output Modes

- `--output json` (default): stable machine-readable payload for cron/agent pipelines
- `--output pretty`: human-readable terminal summary

### Exit Codes

- `0` success
- `2` invalid CLI arguments
- `3` missing configuration (API keys)
- `4` external API failure
- `5` parsing/normalization error

## Testing

Run the test runner:

```bash
python3 scripts/test_surf_report.py
```

It validates valid/invalid argument combinations and JSON output shape (including gust and water temperature fields).

## Cron / Agent Pipeline

Recommended cron-style usage:

```bash
python3 scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --output json
```

Optional post-normalization for strict schema defaults:

```bash
python3 scripts/surf_report.py --lat 50.735 --lon -1.705 --horizon 72h --output json | \
python3 scripts/normalize_surf_data.py
```

## Best Practices

Use the skill responsibly and keep API usage sustainable:

- Cache responses and avoid rapid polling; keep request rates gentle to public endpoints.
- Persist fetched data locally where appropriate to reduce repeated lookups and duplicate requests.
- Validate and adapt to endpoint field changes by inspecting returned JSON before downstream assumptions.

## Further Documentation

- Detailed API behavior and schema: [reference.md](reference.md)
- Practical command and pipeline examples: [examples.md](examples.md)
- Skill contract for other agents: [SKILL.md](SKILL.md)


path: scripts/test_surf_report.py
content: #!/usr/bin/env python3
"""
Test runner for scripts/surf_report.py.

This script executes the CLI with multiple argument combinations and verifies:
- Success/failure exit codes
- JSON output shape and key fields
- Handling of location mode and coordinate mode
- Argument validation failures

Uses --mock to avoid external API calls and credentials.
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from typing import List


ROOT = Path(__file__).resolve().parent.parent
CLI = ROOT / "scripts" / "surf_report.py"
PYTHON_BIN = "/usr/bin/python3"


def run_case(args: List[str]) -> subprocess.CompletedProcess[str]:
    cmd = [PYTHON_BIN, str(CLI)] + args
    return subprocess.run(cmd, capture_output=True, text=True, check=False, cwd=str(ROOT))


def assert_json_shape(stdout: str) -> None:
    payload = json.loads(stdout)
    for key in ("meta", "location", "now", "forecast", "tides"):
        if key not in payload:
            raise AssertionError(f"Missing top-level key: {key}")
    now = payload["now"]
    for key in (
        "waveHeightM",
        "swellHeightM",
        "swellPeriodS",
        "swellDirectionDeg",
        "windSpeedMps",
        "windDirectionDeg",
        "windGustMps",
        "waterTemperatureC",
    ):
        if key not in now:
            raise AssertionError(f"Missing now.{key}")


def main() -> int:
    failures = []

    cases = [
        {
            "name": "location now json mock",
            "args": ["--location", "Highcliffe Beach", "--horizon", "now", "--mock", "--output", "json"],
            "expect_code": 0,
            "expect_json": True,
        },
        {
            "name": "coords 24h json mock",
            "args": ["--lat", "50.735", "--lon", "-1.705", "--horizon", "24h", "--mock", "--output", "json"],
            "expect_code": 0,
            "expect_json": True,
        },
        {
            "name": "coords 72h pretty mock",
            "args": ["--lat", "50.735", "--lon", "-1.705", "--horizon", "72h", "--mock", "--output", "pretty"],
            "expect_code": 0,
            "expect_json": False,
        },
        {
            "name": "missing location and coords",
            "args": ["--horizon", "24h", "--mock"],
            "expect_code": 2,
            "expect_json": False,
        },
        {
            "name": "partial coords missing lon",
            "args": ["--lat", "50.735", "--mock"],
            "expect_code": 2,
            "expect_json": False,
        },
        {
            "name": "location and coords together invalid",
            "args": ["--location", "Highcliffe Beach", "--lat", "50.735", "--lon", "-1.705", "--mock"],
            "expect_code": 2,
            "expect_json": False,
        },
    ]

    for case in cases:
        proc = run_case(case["args"])
        ok = proc.returncode == case["expect_code"]
        if ok and case["expect_json"]:
            try:
                assert_json_shape(proc.stdout)
            except Exception as exc:
                ok = False
                failures.append(f"{case['name']}: json validation failed ({exc})")
        if not ok:
            failures.append(
                f"{case['name']}: expected code {case['expect_code']}, got {proc.returncode}. "
                f"stderr={proc.stderr.strip()!r}"
            )

    if failures:
        print("TEST FAILURES:", file=sys.stderr)
        for item in failures:
            print(f"- {item}", file=sys.stderr)
        return 1

    print(f"All {len(cases)} test cases passed.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/normalize_surf_data.py
content: #!/usr/bin/env python3
"""
Optional normalization helper for surf_report JSON.

This utility is intentionally stdlib-only and can be used by other agents/jobs to
enforce stable null/field behavior before downstream rendering.

Usage:
  cat raw.json | python scripts/normalize_surf_data.py > normalized.json
"""

from __future__ import annotations

import json
import sys
from typing import Any, Dict


NOW_KEYS = [
    "waveHeightM",
    "swellHeightM",
    "swellPeriodS",
    "swellDirectionDeg",
    "windSpeedMps",
    "windDirectionDeg",
    "windGustMps",
    "waterTemperatureC",
]


def normalize(payload: Dict[str, Any]) -> Dict[str, Any]:
    payload.setdefault("meta", {})
    payload.setdefault("location", {})
    payload.setdefault("now", {})
    payload.setdefault("forecast", {})
    payload.setdefault("tides", {})

    for key in NOW_KEYS:
        payload["now"].setdefault(key, None)

    payload["meta"].setdefault("warnings", [])
    payload["forecast"].setdefault("windows", {})
    payload["tides"].setdefault("extremes", [])
    payload["tides"].setdefault("byDay", {})
    payload["tides"].setdefault("trendNow", "unknown")
    return payload


def main() -> int:
    try:
        raw = sys.stdin.read()
        payload = json.loads(raw)
        if not isinstance(payload, dict):
            print("Expected top-level JSON object", file=sys.stderr)
            return 2
        print(json.dumps(normalize(payload), ensure_ascii=True, sort_keys=True))
        return 0
    except json.JSONDecodeError as exc:
        print(f"Invalid JSON: {exc}", file=sys.stderr)
        return 2
    except Exception as exc:
        print(f"Unexpected error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/surf_report.py
content: #!/usr/bin/env python3
"""
Stormglass surf report CLI.

Purpose:
- Fetch surf-relevant conditions from Stormglass for now and forecast horizons.
- Support both spot-name lookup (Google Geocoding) and direct coordinates.
- Emit stable JSON for cron/downstream-agent pipelines.

Usage examples:
  python scripts/surf_report.py --location "Highcliffe Beach" --horizon 72h --output json
  python scripts/surf_report.py --lat 50.735 --lon -1.705 --horizon 24h --output json
  python scripts/surf_report.py --location "Highcliffe Beach" --horizon now --mock --output pretty

Environment:
  STORMGLASS_API_KEY         Required unless --mock.
  GOOGLE_GEOCODING_API_KEY   Required when --location unless --mock.

Exit codes:
  0 success
  2 invalid CLI usage
  3 missing configuration/API keys
  4 external API failure
  5 parsing/normalization failure
"""

from __future__ import annotations

import argparse
import json
import math
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple

WEATHER_ENDPOINT = "https://api.stormglass.io/v2/weather/point"
TIDE_ENDPOINTS = [
    "https://api.stormglass.io/v2/tide/extremes/point",
    "https://api.stormglass.io/v2/tide/extremes",
]
GEOCODE_ENDPOINT = "https://maps.googleapis.com/maps/api/geocode/json"
OSM_GEOCODE_ENDPOINT = "https://nominatim.openstreetmap.org/search"

DEFAULT_SOURCE_ORDER = ["sg", "icon", "gfs", "ecmwf", "dwd", "noaa"]

METRIC_MAP = {
    "waveHeight": "waveHeightM",
    "swellHeight": "swellHeightM",
    "swellPeriod": "swellPeriodS",
    "swellDirection": "swellDirectionDeg",
    "windSpeed": "windSpeedMps",
    "windDirection": "windDirectionDeg",
    "gust": "windGustMps",
    "waterTemperature": "waterTemperatureC",
}

HORIZON_HOURS = {"now": 1, "24h": 24, "48h": 48, "72h": 72}


class ApiError(RuntimeError):
    """Raised for external API errors."""


class ConfigError(RuntimeError):
    """Raised for missing local configuration such as API keys."""


def now_utc() -> datetime:
    return datetime.now(timezone.utc).replace(microsecond=0)


def iso_z(dt: datetime) -> str:
    return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")


def parse_time(value: str) -> datetime:
    if value.endswith("Z"):
        value = value[:-1] + "+00:00"
    return datetime.fromisoformat(value).astimezone(timezone.utc)


def to_unix_seconds(dt: datetime) -> int:
    return int(dt.astimezone(timezone.utc).timestamp())


def http_get_any_json(url: str, headers: Optional[Dict[str, str]], timeout: int) -> Any:
    req = urllib.request.Request(url=url, headers=headers or {}, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as response:
            body = response.read().decode("utf-8")
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise ApiError(f"HTTP {exc.code} for {url}: {detail}") from exc
    except urllib.error.URLError as exc:
        raise ApiError(f"Network error for {url}: {exc}") from exc

    try:
        parsed = json.loads(body)
    except json.JSONDecodeError as exc:
        raise ApiError(f"Invalid JSON from {url}: {exc}") from exc
    return parsed


def http_get_json(url: str, headers: Optional[Dict[str, str]], timeout: int) -> Dict[str, Any]:
    parsed = http_get_any_json(url=url, headers=headers, timeout=timeout)
    if not isinstance(parsed, dict):
        raise ApiError(f"Unexpected payload from {url}; expected JSON object")
    return parsed


def parse_sources(value: Optional[str]) -> List[str]:
    if not value:
        return []
    return [part.strip() for part in value.split(",") if part.strip()]


def select_metric_value(raw: Any, source_order: List[str]) -> Optional[float]:
    if raw is None:
        return None
    if isinstance(raw, (int, float)):
        return float(raw)
    if not isinstance(raw, dict):
        return None

    ordered = source_order + [src for src in DEFAULT_SOURCE_ORDER if src not in source_order]
    for key in ordered:
        if key in raw and raw[key] is not None:
            try:
                return float(raw[key])
            except (TypeError, ValueError):
                continue
    for val in raw.values():
        if val is not None:
            try:
                return float(val)
            except (TypeError, ValueError):
                continue
    return None


def geocode_location(address: str, api_key: str, timeout: int) -> Tuple[Dict[str, Any], List[str]]:
    query = urllib.parse.urlencode({"address": address, "key": api_key})
    payload = http_get_json(f"{GEOCODE_ENDPOINT}?{query}", headers=None, timeout=timeout)

    status = payload.get("status")
    if status != "OK":
        err = payload.get("error_message", status)
        raise ApiError(f"Geocoding failed: {err}")

    results = payload.get("results", [])
    if not results:
        raise ApiError("Geocoding returned no results")

    top = results[0]
    geometry = top.get("geometry", {}).get("location", {})
    lat = geometry.get("lat")
    lon = geometry.get("lng")
    if lat is None or lon is None:
        raise ApiError("Geocoding response missing coordinates")

    warnings: List[str] = []
    if len(results) > 1:
        warnings.append("Location query returned multiple matches; top result used.")

    location = {
        "query": address,
        "resolvedName": top.get("formatted_address", address),
        "lat": float(lat),
        "lon": float(lon),
        "googlePlaceId": top.get("place_id"),
    }
    return location, warnings


def geocode_location_osm(address: str, timeout: int) -> Tuple[Dict[str, Any], List[str]]:
    query = urllib.parse.urlencode(
        {
            "q": address,
            "format": "jsonv2",
            "limit": "5",
            "addressdetails": "0",
        }
    )
    payload = http_get_any_json(
        f"{OSM_GEOCODE_ENDPOINT}?{query}",
        headers={"User-Agent": "stormglass-surf-skill/1.0 (+openclaw-cron)"},
        timeout=timeout,
    )
    # Nominatim returns a top-level array; use direct request path here.
    if isinstance(payload, dict):
        # Defensive compatibility if proxy/wrapper returns object.
        results = payload.get("results", [])
    else:
        results = payload
    if not isinstance(results, list) or not results:
        raise ApiError("OSM geocoding returned no results")

    top = results[0]
    try:
        lat = float(top.get("lat"))
        lon = float(top.get("lon"))
    except (TypeError, ValueError) as exc:
        raise ApiError("OSM geocoding response missing valid coordinates") from exc

    warnings: List[str] = [
        "GOOGLE_GEOCODING_API_KEY not set; used OpenStreetMap Nominatim fallback geocoding."
    ]
    if len(results) > 1:
        warnings.append("Location query returned multiple matches; top result used.")

    location = {
        "query": address,
        "resolvedName": top.get("display_name", address),
        "lat": lat,
        "lon": lon,
        "googlePlaceId": None,
    }
    return location, warnings


def fetch_weather(
    lat: float,
    lon: float,
    stormglass_key: str,
    start: datetime,
    end: datetime,
    sources: List[str],
    timeout: int,
) -> List[Dict[str, Any]]:
    params = ",".join(METRIC_MAP.keys())
    query_data = {
        "lat": lat,
        "lng": lon,
        "params": params,
        "start": str(to_unix_seconds(start)),
        "end": str(to_unix_seconds(end)),
    }
    if sources:
        query_data["source"] = ",".join(sources)
    query = urllib.parse.urlencode(query_data)

    payload = http_get_json(
        f"{WEATHER_ENDPOINT}?{query}",
        headers={"Authorization": stormglass_key},
        timeout=timeout,
    )
    hours = payload.get("hours", [])
    if not isinstance(hours, list):
        raise ApiError("Weather response missing hours array")
    return hours


def fetch_tides(
    lat: float,
    lon: float,
    stormglass_key: str,
    start: datetime,
    end: datetime,
    timeout: int,
) -> List[Dict[str, Any]]:
    query = urllib.parse.urlencode(
        {
            "lat": lat,
            "lng": lon,
            "start": start.date().isoformat(),
            "end": end.date().isoformat(),
        }
    )
    last_error: Optional[ApiError] = None
    for endpoint in TIDE_ENDPOINTS:
        try:
            payload = http_get_json(
                f"{endpoint}?{query}",
                headers={"Authorization": stormglass_key},
                timeout=timeout,
            )
            data = payload.get("data", [])
            if isinstance(data, list):
                return data
            raise ApiError("Tide response missing data array")
        except ApiError as exc:
            last_error = exc
    if last_error:
        raise last_error
    return []


def normalize_hour(hour: Dict[str, Any], source_order: List[str]) -> Dict[str, Any]:
    out: Dict[str, Any] = {"time": hour.get("time")}
    for raw_key, out_key in METRIC_MAP.items():
        out[out_key] = select_metric_value(hour.get(raw_key), source_order)
    return out


def nearest_hour(hours: List[Dict[str, Any]], anchor: datetime) -> Dict[str, Any]:
    def distance(hour: Dict[str, Any]) -> float:
        return abs((parse_time(str(hour["time"])) - anchor).total_seconds())

    valid = [h for h in hours if "time" in h]
    if not valid:
        raise ApiError("No weather hours with time field")
    return min(valid, key=distance)


def score_hour(hour: Dict[str, Any]) -> float:
    wave = hour.get("waveHeightM")
    period = hour.get("swellPeriodS")
    wind = hour.get("windSpeedMps")
    gust = hour.get("windGustMps")

    score = 0.0
    if wave is not None:
        score += max(0.0, 1.2 - abs(wave - 1.2))
    if period is not None:
        score += min(2.0, period / 6.0)
    if wind is not None:
        score += max(0.0, 2.0 - wind / 3.0)
    if gust is not None:
        score += max(0.0, 1.5 - gust / 4.0)
    return round(score, 4)


def build_windows(
    normalized_hours: List[Dict[str, Any]],
    anchor: datetime,
    horizon: str,
) -> Dict[str, Any]:
    requested = HORIZON_HOURS[horizon]
    windows: Dict[str, Any] = {}
    for label, hours in [("24h", 24), ("48h", 48), ("72h", 72)]:
        if hours > requested or horizon == "now":
            continue
        end = anchor + timedelta(hours=hours)
        in_window = []
        for h in normalized_hours:
            t = parse_time(str(h["time"]))
            if anchor <= t <= end:
                entry = dict(h)
                entry["score"] = score_hour(h)
                in_window.append(entry)
        top = sorted(in_window, key=lambda x: x["score"], reverse=True)[:3]
        windows[label] = {
            "start": iso_z(anchor),
            "end": iso_z(end),
            "bestHours": top,
        }
    return windows


def normalize_tides(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    for item in data:
        t = item.get("time")
        if not t:
            continue
        item_type = item.get("type")
        height = item.get("height")
        try:
            height_value = float(height) if height is not None else None
        except (TypeError, ValueError):
            height_value = None
        out.append({"time": t, "type": item_type, "heightM": height_value})
    out.sort(key=lambda x: x["time"])
    return out


def tide_trend_now(extremes: List[Dict[str, Any]], anchor: datetime) -> str:
    parsed: List[Tuple[datetime, str]] = []
    for e in extremes:
        try:
            parsed.append((parse_time(str(e["time"])), str(e.get("type", "")).lower()))
        except Exception:
            continue
    if not parsed:
        return "unknown"
    prev_item = None
    next_item = None
    for dt, kind in parsed:
        if dt <= anchor:
            prev_item = (dt, kind)
        elif dt > anchor and next_item is None:
            next_item = (dt, kind)
    if prev_item and next_item:
        prev_kind = prev_item[1]
        next_kind = next_item[1]
        if prev_kind == "low" and next_kind == "high":
            return "rising"
        if prev_kind == "high" and next_kind == "low":
            return "falling"
    return "unknown"


def tides_by_day(extremes: List[Dict[str, Any]]) -> Dict[str, Dict[str, List[Dict[str, Any]]]]:
    out: Dict[str, Dict[str, List[Dict[str, Any]]]] = {}
    for e in extremes:
        try:
            day = parse_time(str(e["time"])).date().isoformat()
        except Exception:
            continue
        out.setdefault(day, {"high": [], "low": []})
        kind = str(e.get("type", "")).lower()
        if kind not in ("high", "low"):
            continue
        out[day][kind].append({"time": e["time"], "heightM": e.get("heightM")})
    return out


def mock_payload(
    anchor: datetime, horizon: str, location_mode: str, lat: float, lon: float, location_query: Optional[str]
) -> Dict[str, Any]:
    hours = HORIZON_HOURS[horizon] if horizon != "now" else 24
    normalized_hours: List[Dict[str, Any]] = []
    for i in range(hours + 1):
        t = anchor + timedelta(hours=i)
        normalized_hours.append(
            {
                "time": iso_z(t),
                "waveHeightM": round(0.9 + 0.5 * math.sin(i / 6.0), 2),
                "swellHeightM": round(0.7 + 0.3 * math.cos(i / 7.0), 2),
                "swellPeriodS": round(8.0 + 2.0 * math.sin(i / 8.0), 2),
                "swellDirectionDeg": round(240 + 15 * math.cos(i / 12.0), 1),
                "windSpeedMps": round(4.0 + 2.0 * math.sin(i / 5.0), 2),
                "windDirectionDeg": round(210 + 25 * math.cos(i / 9.0), 1),
                "windGustMps": round(6.0 + 2.5 * math.sin(i / 5.0), 2),
                "waterTemperatureC": round(10.5 + 0.8 * math.sin(i / 24.0), 2),
            }
        )

    extremes: List[Dict[str, Any]] = []
    ext_count = max(6, int((HORIZON_HOURS.get(horizon, 72) / 6) + 2))
    kind = "low"
    for i in range(ext_count):
        t = anchor + timedelta(hours=i * 6)
        kind = "high" if kind == "low" else "low"
        extremes.append(
            {
                "time": iso_z(t),
                "type": kind,
                "heightM": round(1.0 + (0.8 if kind == "high" else -0.3), 2),
            }
        )

    nearest = nearest_hour(normalized_hours, anchor)
    return {
        "meta": {
            "generatedAt": iso_z(anchor),
            "horizon": horizon,
            "inputMode": location_mode,
            "sourcesRequested": [],
            "warnings": ["Mock mode enabled: no external API calls were made."],
        },
        "location": {
            "query": location_query,
            "resolvedName": location_query if location_query else f"{lat},{lon}",
            "lat": lat,
            "lon": lon,
            "googlePlaceId": None,
        },
        "now": nearest,
        "forecast": {"windows": build_windows(normalized_hours, anchor, horizon)},
        "tides": {
            "trendNow": tide_trend_now(extremes, anchor),
            "extremes": extremes,
            "byDay": tides_by_day(extremes),
        },
    }


def build_report(args: argparse.Namespace) -> Dict[str, Any]:
    anchor = now_utc()
    sources = parse_sources(args.source)
    warnings: List[str] = []

    if args.location:
        mode = "location"
        location_query = args.location
    else:
        mode = "coordinates"
        location_query = None

    lat: float
    lon: float
    location: Dict[str, Any]

    if args.mock:
        lat = args.lat if args.lat is not None else 50.735
        lon = args.lon if args.lon is not None else -1.705
        return mock_payload(anchor, args.horizon, mode, lat, lon, location_query)

    stormglass_key = os.environ.get("STORMGLASS_API_KEY")
    if not stormglass_key:
        raise ConfigError("Missing STORMGLASS_API_KEY environment variable.")

    if args.location:
        geocode_key = os.environ.get("GOOGLE_GEOCODING_API_KEY")
        if geocode_key:
            location, geo_warnings = geocode_location(args.location, geocode_key, args.timeout)
        else:
            location, geo_warnings = geocode_location_osm(args.location, args.timeout)
        warnings.extend(geo_warnings)
        lat = location["lat"]
        lon = location["lon"]
    else:
        lat = float(args.lat)
        lon = float(args.lon)
        location = {
            "query": None,
            "resolvedName": f"{lat},{lon}",
            "lat": lat,
            "lon": lon,
            "googlePlaceId": None,
        }

    end = anchor + timedelta(hours=HORIZON_HOURS[args.horizon])
    weather_hours = fetch_weather(lat, lon, stormglass_key, anchor, end, sources, args.timeout)
    tide_data = fetch_tides(lat, lon, stormglass_key, anchor, end + timedelta(days=1), args.timeout)

    source_order = sources[:] if sources else []
    normalized = [normalize_hour(hour, source_order) for hour in weather_hours if "time" in hour]
    if not normalized:
        raise ApiError("No weather data points returned")

    current = nearest_hour(normalized, anchor)
    extremes = normalize_tides(tide_data)

    return {
        "meta": {
            "generatedAt": iso_z(anchor),
            "horizon": args.horizon,
            "inputMode": mode,
            "sourcesRequested": sources,
            "warnings": warnings,
        },
        "location": location,
        "now": current,
        "forecast": {"windows": build_windows(normalized, anchor, args.horizon)},
        "tides": {
            "trendNow": tide_trend_now(extremes, anchor),
            "extremes": extremes,
            "byDay": tides_by_day(extremes),
        },
    }


def to_pretty(report: Dict[str, Any]) -> str:
    now = report.get("now", {})
    location = report.get("location", {})
    lines = [
        f"Surf report for: {location.get('resolvedName')}",
        f"Generated: {report.get('meta', {}).get('generatedAt')}",
        "",
        "Now:",
        f"  Wave: {now.get('waveHeightM')} m",
        f"  Swell: {now.get('swellHeightM')} m @ {now.get('swellPeriodS')} s ({now.get('swellDirectionDeg')} deg)",
        f"  Wind: {now.get('windSpeedMps')} m/s gust {now.get('windGustMps')} m/s ({now.get('windDirectionDeg')} deg)",
        f"  Water temp: {now.get('waterTemperatureC')} C",
        f"  Tide trend: {report.get('tides', {}).get('trendNow')}",
    ]
    windows = report.get("forecast", {}).get("windows", {})
    if windows:
        lines.append("")
        lines.append("Best windows:")
        for label, data in windows.items():
            best = data.get("bestHours", [])
            if not best:
                continue
            top = best[0]
            lines.append(
                f"  {label}: {top.get('time')} (score {top.get('score')}, "
                f"wave {top.get('waveHeightM')} m, period {top.get('swellPeriodS')} s)"
            )
    return "\n".join(lines)


def parse_args(argv: List[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Fetch surf-relevant Stormglass data by location or coordinates.")
    parser.add_argument("--location", help="Surf spot/place name for geocoding lookup.")
    parser.add_argument("--lat", type=float, help="Latitude for direct coordinate mode.")
    parser.add_argument("--lon", type=float, help="Longitude for direct coordinate mode.")
    parser.add_argument("--horizon", choices=["now", "24h", "48h", "72h"], default="72h")
    parser.add_argument("--output", choices=["json", "pretty"], default="json")
    parser.add_argument("--source", help="Optional comma-separated Stormglass sources.")
    parser.add_argument("--timeout", type=int, default=20, help="HTTP timeout seconds.")
    parser.add_argument("--mock", action="store_true", help="Use deterministic offline mock data.")

    args = parser.parse_args(argv)

    has_location = bool(args.location)
    has_coords = args.lat is not None or args.lon is not None
    if has_location and has_coords:
        parser.error("Use either --location or --lat/--lon, not both.")
    if not has_location and not has_coords:
        parser.error("Provide --location or both --lat and --lon.")
    if has_coords and (args.lat is None or args.lon is None):
        parser.error("Both --lat and --lon are required for coordinate mode.")
    return args


def main(argv: List[str]) -> int:
    try:
        args = parse_args(argv)
        report = build_report(args)
        if args.output == "json":
            print(json.dumps(report, ensure_ascii=True, sort_keys=True))
        else:
            print(to_pretty(report))
        return 0
    except SystemExit as exc:
        if isinstance(exc.code, int):
            if exc.code in (0, 2, 3):
                return exc.code
            return 2
        return 2
    except ApiError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 4
    except ConfigError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 3
    except Exception as exc:  # pragma: no cover - safety catch for cron robustness
        print(f"ERROR: {exc}", file=sys.stderr)
        return 5


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))


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