Files
conai/backend/app/services/weather_service.py
sinmb79 2a4950d8a0 feat: CONAI Phase 1 MVP 초기 구현
소형 건설업체(100억 미만)를 위한 AI 기반 토목공사 통합관리 플랫폼

Backend (FastAPI):
- SQLAlchemy 모델 13개 (users, projects, wbs, tasks, daily_reports, reports, inspections, quality, weather, permits, rag, settings)
- API 라우터 11개 (auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings)
- Services: Claude AI 래퍼, CPM Gantt 계산, 기상청 API, RAG(pgvector), 카카오 Skill API
- Alembic 마이그레이션 (pgvector 포함)
- pytest 테스트 (CPM, 날씨 경보)

Frontend (Next.js 15):
- 11개 페이지 (대시보드, 프로젝트, Gantt, 일보, 검측, 품질, 날씨, 인허가, RAG, 설정)
- TanStack Query + Zustand + Tailwind CSS

인프라:
- Docker Compose (PostgreSQL pgvector + backend + frontend)
- 한국어 README 및 설치 가이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:06:36 +09:00

203 lines
7.3 KiB
Python

"""
기상청 Open API (KMA) integration.
Fetches short-term (단기예보) and medium-term (중기예보) forecasts.
"""
import httpx
from datetime import date, datetime, timedelta, timezone
from typing import Any
from app.config import settings
KMA_BASE = settings.KMA_BASE_URL
API_KEY = settings.KMA_API_KEY
# Weather code -> Korean description
WEATHER_CODE_MAP = {
"1": "맑음", "2": "구름조금", "3": "구름많음",
"4": "흐림", "5": "", "6": "비눈", "7": "눈비",
"8": "",
}
async def fetch_short_term_forecast(nx: int, ny: int) -> list[dict]:
"""Fetch 단기예보 (3-day, 3-hour interval)."""
now = datetime.now(timezone.utc).astimezone()
# KMA issues forecasts at 02, 05, 08, 11, 14, 17, 20, 23
base_hours = [2, 5, 8, 11, 14, 17, 20, 23]
current_hour = now.hour
base_hour = max([h for h in base_hours if h <= current_hour], default=23)
base_date = now.strftime("%Y%m%d") if current_hour >= 2 else (now - timedelta(days=1)).strftime("%Y%m%d")
base_time = f"{base_hour:02d}00"
params = {
"serviceKey": API_KEY,
"pageNo": 1,
"numOfRows": 1000,
"dataType": "JSON",
"base_date": base_date,
"base_time": base_time,
"nx": nx,
"ny": ny,
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.get(f"{KMA_BASE}/getVilageFcst", params=params)
resp.raise_for_status()
data = resp.json()
items = data.get("response", {}).get("body", {}).get("items", {}).get("item", [])
return _parse_short_term(items)
def _parse_short_term(items: list[dict]) -> list[dict]:
"""Parse KMA short-term forecast items into daily summaries."""
daily: dict[str, dict] = {}
for item in items:
fcst_date = item.get("fcstDate", "")[:8] # YYYYMMDD
category = item.get("category", "")
value = item.get("fcstValue", "")
if fcst_date not in daily:
daily[fcst_date] = {
"date": f"{fcst_date[:4]}-{fcst_date[4:6]}-{fcst_date[6:]}",
"temp_max": None, "temp_min": None,
"precipitation": 0.0, "wind_speed": None,
"sky": None, "pty": None,
}
d = daily[fcst_date]
if category == "TMX" and value != "-":
d["temp_max"] = float(value)
elif category == "TMN" and value != "-":
d["temp_min"] = float(value)
elif category == "PCP" and value not in ("-", "강수없음"):
try:
d["precipitation"] = max(d["precipitation"], float(value.replace("mm", "").strip()))
except ValueError:
pass
elif category == "WSD":
try:
ws = float(value)
if d["wind_speed"] is None or ws > d["wind_speed"]:
d["wind_speed"] = ws
except ValueError:
pass
elif category == "SKY":
d["sky"] = value
elif category == "PTY":
d["pty"] = value
result = []
for fcst_date in sorted(daily.keys()):
d = daily[fcst_date]
weather_code = d.get("pty") or d.get("sky") or "1"
result.append({
"date": d["date"],
"temperature_high": d["temp_max"],
"temperature_low": d["temp_min"],
"precipitation_mm": d["precipitation"],
"wind_speed_ms": d["wind_speed"],
"weather_code": weather_code,
"weather_desc": WEATHER_CODE_MAP.get(str(weather_code), "알 수 없음"),
})
return result
# --- Weather Constraint Evaluation ---
# Default constraints by work type code
DEFAULT_CONSTRAINTS: dict[str, dict] = {
"CONCRETE": {"min_temp": 5.0, "max_wind": None, "no_rain": True},
"HIGH_WORK": {"min_temp": None, "max_wind": 10.0, "no_rain": False},
"ASPHALT": {"min_temp": 10.0, "max_wind": None, "no_rain": True},
"EARTHWORK": {"min_temp": None, "max_wind": None, "no_rain": True},
"REBAR": {"min_temp": None, "max_wind": None, "no_rain": False},
}
def evaluate_weather_alerts(
forecast: dict,
tasks_on_date: list,
work_type_constraints: dict[str, dict] | None = None,
) -> list[dict]:
"""
Evaluate weather constraints for tasks on a given date.
Returns list of alert dicts.
"""
alerts = []
constraints = work_type_constraints or DEFAULT_CONSTRAINTS
for task in tasks_on_date:
# Determine work type from task name (simple keyword matching)
work_type = _detect_work_type(task.name)
if not work_type or work_type not in constraints:
continue
constraint = constraints[work_type]
temp_low = forecast.get("temperature_low")
wind_speed = forecast.get("wind_speed_ms")
precipitation = forecast.get("precipitation_mm", 0)
# Check temperature
if constraint.get("min_temp") and temp_low is not None:
if temp_low < constraint["min_temp"]:
alerts.append({
"task_id": str(task.id),
"alert_date": forecast.get("date"),
"alert_type": f"cold_{work_type.lower()}",
"severity": "critical" if temp_low < constraint["min_temp"] - 5 else "warning",
"message": (
f"[{task.name}] 최저기온 {temp_low}°C - "
f"{work_type} 작업 기준온도({constraint['min_temp']}°C) 미달. "
f"작업 조정 검토 필요."
),
})
# Check wind
if constraint.get("max_wind") and wind_speed is not None:
if wind_speed > constraint["max_wind"]:
alerts.append({
"task_id": str(task.id),
"alert_date": forecast.get("date"),
"alert_type": f"wind_{work_type.lower()}",
"severity": "critical",
"message": (
f"[{task.name}] 풍속 {wind_speed}m/s - "
f"허용 최대풍속({constraint['max_wind']}m/s) 초과. "
f"고소작업 중단 검토."
),
})
# Check rain
if constraint.get("no_rain") and precipitation and precipitation > 1.0:
alerts.append({
"task_id": str(task.id),
"alert_date": forecast.get("date"),
"alert_type": f"rain_{work_type.lower()}",
"severity": "warning",
"message": (
f"[{task.name}] 강수 예보 {precipitation}mm - "
f"{work_type} 작업 우천 시 제한. 공정 조정 검토."
),
})
return alerts
def _detect_work_type(task_name: str) -> str | None:
"""Simple keyword-based work type detection from task name."""
name_lower = task_name.lower()
if any(k in name_lower for k in ["콘크리트", "타설", "레미콘"]):
return "CONCRETE"
if any(k in name_lower for k in ["고소", "크레인", "비계", "거푸집"]):
return "HIGH_WORK"
if any(k in name_lower for k in ["아스팔트", "포장"]):
return "ASPHALT"
if any(k in name_lower for k in ["성토", "절토", "굴착", "토공"]):
return "EARTHWORK"
if any(k in name_lower for k in ["철근", "배근"]):
return "REBAR"
return None