New skill: - .smallclaw/skills/meteorologist/SKILL.md — 기상 전문가 스킬 (Windy iframe 규칙 포함) New weather tools (src/tools/weather.ts): - weather_airpollution: OpenWeather 대기질 (AQI, PM2.5/PM10/O3/NO2) - weather_openmeteo: Open-Meteo 시간별 예보 (ECMWF/GFS, 키 불필요) - weather_kma: 기상청 공식 API (초단기실황·단기예보) - weather_airkorea: 에어코리아 실시간 대기질 - weather_nasa_power: NASA POWER 기후 데이터 (MERRA-2, 키 불필요) - weather_era5: ERA5 재분석 과거 데이터 (Open-Meteo Historical, 키 불필요) - weather_cds: Copernicus CDS 정식 API (ERA5·CMIP6 SSP 시나리오) - weather_cmip6: CMIP6 기후 모델 (Open-Meteo Climate API, 키 불필요) Scripts: - scripts/era5_cds_fetch.py — CDS/CMIP6 Python 헬퍼 (ZIP→NetCDF 처리 포함) web-ui: - Windy URL → iframe 자동 렌더링 (standalone URL + 마크다운 링크 모두 감지) - windyToEmbedUrl: windy.com comma 형식 URL 파싱 개선 server-v2.ts: - BROWSER RULE: "열어줘" 제거 → 오용 방지 - weather context: 9개 도구 선택 기준 추가 - JSON schema 8개 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
6.0 KiB
Python
164 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
CDS API helper — fetches ERA5/CMIP6 data and prints JSON to stdout.
|
|
Called by weather_cds tool with a JSON request on stdin.
|
|
|
|
Input (stdin, JSON):
|
|
{
|
|
"url": "https://cds.climate.copernicus.eu/api",
|
|
"key": "<personal-access-token>",
|
|
"dataset": "reanalysis-era5-single-levels",
|
|
"request": { ... CDS request dict ... },
|
|
"lat": 37.5,
|
|
"lon": 126.9
|
|
}
|
|
|
|
Output (stdout, JSON):
|
|
{ "ok": true, "data": { variable: {...} }, "times": [...], "meta": {...} }
|
|
{ "ok": false, "error": "..." }
|
|
"""
|
|
|
|
import sys, json, os, tempfile, zipfile
|
|
|
|
def main():
|
|
inp = json.load(sys.stdin)
|
|
url = inp.get("url", "https://cds.climate.copernicus.eu/api")
|
|
key = inp.get("key", "")
|
|
dataset = inp.get("dataset", "reanalysis-era5-single-levels")
|
|
request = inp.get("request", {})
|
|
lat = inp.get("lat")
|
|
lon = inp.get("lon")
|
|
|
|
try:
|
|
import cdsapi
|
|
except ImportError:
|
|
print(json.dumps({"ok": False, "error": "cdsapi 미설치. pip install cdsapi --break-system-packages"}))
|
|
return
|
|
|
|
try:
|
|
import netCDF4 as nc4
|
|
import numpy as np
|
|
except ImportError:
|
|
print(json.dumps({"ok": False, "error": "netCDF4/numpy 미설치. pip install netCDF4 numpy --break-system-packages"}))
|
|
return
|
|
|
|
tmpzip = None
|
|
tmpnc = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
|
|
tmpzip = f.name
|
|
|
|
client = cdsapi.Client(url=url, key=key, quiet=True, progress=False)
|
|
client.retrieve(dataset, request, tmpzip)
|
|
|
|
# CDS now returns a ZIP containing NetCDF + provenance files
|
|
with tempfile.NamedTemporaryFile(suffix=".nc", delete=False) as f:
|
|
tmpnc = f.name
|
|
|
|
if zipfile.is_zipfile(tmpzip):
|
|
with zipfile.ZipFile(tmpzip) as z:
|
|
nc_names = [n for n in z.namelist() if n.endswith(".nc")]
|
|
if not nc_names:
|
|
raise RuntimeError("ZIP에 .nc 파일이 없습니다: " + str(z.namelist()))
|
|
with open(tmpnc, "wb") as out:
|
|
out.write(z.read(nc_names[0]))
|
|
else:
|
|
# Not a ZIP — assume raw NetCDF
|
|
import shutil
|
|
shutil.copy(tmpzip, tmpnc)
|
|
|
|
ds = nc4.Dataset(tmpnc)
|
|
result = {"ok": True, "data": {}, "times": [], "meta": {}}
|
|
|
|
# --- time axis ---
|
|
if "time" in ds.variables:
|
|
t_var = ds["time"]
|
|
t_units = getattr(t_var, "units", "")
|
|
t_cal = getattr(t_var, "calendar", "standard")
|
|
try:
|
|
import cftime
|
|
dts = nc4.num2date(t_var[:], t_units, t_cal)
|
|
result["times"] = [str(d)[:16] for d in dts]
|
|
except Exception:
|
|
result["times"] = [str(v) for v in t_var[:].tolist()]
|
|
|
|
# --- data variables (exclude coordinate/bound variables) ---
|
|
COORD_VARS = {"time", "time_bnds", "lat", "lat_bnds", "lon", "lon_bnds",
|
|
"latitude", "longitude", "height", "level", "plev", "bnds"}
|
|
data_vars = [v for v in ds.variables if v not in COORD_VARS]
|
|
|
|
for vname in data_vars:
|
|
var = ds[vname]
|
|
units = getattr(var, "units", "")
|
|
long_name = getattr(var, "long_name", vname)
|
|
|
|
raw = var[:].filled(np.nan) if hasattr(var[:], "filled") else np.array(var[:])
|
|
|
|
# Nearest-point extraction when lat/lon given
|
|
if lat is not None and lon is not None:
|
|
lat_dim = lon_dim = None
|
|
for dim in var.dimensions:
|
|
if dim in ("lat", "latitude"): lat_dim = dim
|
|
if dim in ("lon", "longitude"): lon_dim = dim
|
|
if lat_dim and lon_dim:
|
|
lat_idx = int(np.argmin(np.abs(ds[lat_dim][:] - lat)))
|
|
lon_idx = int(np.argmin(np.abs(ds[lon_dim][:] - lon)))
|
|
slices = tuple(
|
|
lat_idx if d == lat_dim else (lon_idx if d == lon_dim else slice(None))
|
|
for d in var.dimensions
|
|
)
|
|
raw = raw[slices]
|
|
|
|
vals = raw.flatten().tolist()
|
|
|
|
# Unit conversions
|
|
if units == "K":
|
|
vals = [round(v - 273.15, 3) if v is not None and not (isinstance(v, float) and v != v) else None for v in vals]
|
|
units = "°C"
|
|
elif units == "m s**-1":
|
|
units = "m/s"
|
|
elif units == "m" and ("precip" in vname.lower() or "pr" == vname):
|
|
vals = [round(v * 1000, 3) if v is not None else None for v in vals]
|
|
units = "mm"
|
|
|
|
arr = [v for v in vals if v is not None and isinstance(v, float) and v == v]
|
|
stats = {}
|
|
if arr:
|
|
stats = {"mean": round(float(np.mean(arr)), 3),
|
|
"min": round(float(np.min(arr)), 3),
|
|
"max": round(float(np.max(arr)), 3),
|
|
"n": len(arr)}
|
|
result["data"][vname] = {
|
|
"values": vals, "units": units, "long_name": long_name, "stats": stats
|
|
}
|
|
|
|
# lat/lon info
|
|
for dim in ("lat", "latitude"):
|
|
if dim in ds.variables:
|
|
result["meta"]["lat_grid"] = [round(float(v), 4) for v in ds[dim][:].tolist()]
|
|
break
|
|
for dim in ("lon", "longitude"):
|
|
if dim in ds.variables:
|
|
result["meta"]["lon_grid"] = [round(float(v), 4) for v in ds[dim][:].tolist()]
|
|
break
|
|
|
|
result["meta"].update({
|
|
"dataset": dataset,
|
|
"variables": data_vars,
|
|
"n_times": len(result["times"]),
|
|
})
|
|
|
|
ds.close()
|
|
print(json.dumps(result))
|
|
|
|
except Exception as e:
|
|
print(json.dumps({"ok": False, "error": str(e)}))
|
|
finally:
|
|
for f in [tmpzip, tmpnc]:
|
|
if f and os.path.exists(f):
|
|
try: os.unlink(f)
|
|
except Exception: pass
|
|
|
|
if __name__ == "__main__":
|
|
main()
|