気象データ基盤を実装
- apps/weather 新規作成(WeatherRecord モデル、5種APIエンドポイント) - GET /api/weather/records/ 日次データ一覧 - GET /api/weather/summary/ 月別・年間集計 - GET /api/weather/gdd/ 有効積算温度(GDD)計算 - GET /api/weather/similarity/ 類似年分析(開花・収穫予測の基礎) - POST /api/weather/sync/ Windmill向け日次更新(APIキー認証) - management command: fetch_weather(初回一括・差分取得) - Crop.base_temp フィールド追加(GDD基準温度、default=0.0℃) - docker-compose.yml: MAIL_API_KEY 環境変数を追加(ローカルテスト修正) - requirements.txt: requests>=2.31 追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
163
backend/apps/weather/management/commands/fetch_weather.py
Normal file
163
backend/apps/weather/management/commands/fetch_weather.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
気象データを Open-Meteo API から取得して DB に保存する。
|
||||
|
||||
使い方:
|
||||
# 差分取得(最終レコードの翌日〜昨日)
|
||||
python manage.py fetch_weather
|
||||
|
||||
# 全件取得(初回インポート)
|
||||
python manage.py fetch_weather --full
|
||||
|
||||
# 期間指定
|
||||
python manage.py fetch_weather --start-date 2024-01-01 --end-date 2024-12-31
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import requests
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from apps.weather.models import WeatherRecord
|
||||
|
||||
|
||||
LATITUDE = 33.213
|
||||
LONGITUDE = 133.133
|
||||
TIMEZONE = 'Asia/Tokyo'
|
||||
FULL_START = '2016-01-01'
|
||||
|
||||
OPEN_METEO_URL = 'https://archive-api.open-meteo.com/v1/archive'
|
||||
DAILY_VARS = [
|
||||
'temperature_2m_mean',
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'sunshine_duration',
|
||||
'precipitation_sum',
|
||||
'wind_speed_10m_max',
|
||||
'surface_pressure_min',
|
||||
]
|
||||
|
||||
|
||||
def fetch_from_api(start_date: str, end_date: str) -> list[dict]:
|
||||
"""Open-Meteo から daily データを取得し、WeatherRecord 形式のリストで返す。"""
|
||||
params = {
|
||||
'latitude': LATITUDE,
|
||||
'longitude': LONGITUDE,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'daily': DAILY_VARS,
|
||||
'timezone': TIMEZONE,
|
||||
}
|
||||
resp = requests.get(OPEN_METEO_URL, params=params, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
raise CommandError(f'Open-Meteo API エラー: {resp.status_code} {resp.text[:200]}')
|
||||
|
||||
data = resp.json().get('daily', {})
|
||||
dates = data.get('time', [])
|
||||
|
||||
if not dates:
|
||||
return []
|
||||
|
||||
sunshine_raw = data.get('sunshine_duration', [])
|
||||
results = []
|
||||
for i, d in enumerate(dates):
|
||||
# 日照: 秒 → 時間
|
||||
sun_sec = sunshine_raw[i]
|
||||
sunshine_h = round(sun_sec / 3600, 2) if sun_sec is not None else None
|
||||
|
||||
results.append({
|
||||
'date': d,
|
||||
'temp_mean': data['temperature_2m_mean'][i],
|
||||
'temp_max': data['temperature_2m_max'][i],
|
||||
'temp_min': data['temperature_2m_min'][i],
|
||||
'sunshine_h': sunshine_h,
|
||||
'precip_mm': data['precipitation_sum'][i],
|
||||
'wind_max': data['wind_speed_10m_max'][i],
|
||||
'pressure_min': data['surface_pressure_min'][i],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def upsert_records(records: list[dict], stdout=None) -> int:
|
||||
"""WeatherRecord に upsert し、保存件数を返す。"""
|
||||
saved = 0
|
||||
for item in records:
|
||||
record, created = WeatherRecord.objects.get_or_create(date=item['date'])
|
||||
for field in ['temp_mean', 'temp_max', 'temp_min',
|
||||
'sunshine_h', 'precip_mm', 'wind_max', 'pressure_min']:
|
||||
val = item.get(field)
|
||||
if val is not None:
|
||||
setattr(record, field, val)
|
||||
record.save()
|
||||
saved += 1
|
||||
if stdout and created:
|
||||
stdout.write(f' new: {item["date"]}')
|
||||
return saved
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '気象データを Open-Meteo から取得して DB に保存する'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--full',
|
||||
action='store_true',
|
||||
help=f'2016-01-01 から昨日まで全件取得(初回インポート用)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--start-date',
|
||||
type=str,
|
||||
help='取得開始日 (YYYY-MM-DD)。省略時は最終レコードの翌日',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--end-date',
|
||||
type=str,
|
||||
help='取得終了日 (YYYY-MM-DD)。省略時は昨日',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
# 終了日
|
||||
end_date = options.get('end_date') or yesterday
|
||||
|
||||
# 開始日の決定
|
||||
if options['full']:
|
||||
start_date = FULL_START
|
||||
elif options.get('start_date'):
|
||||
start_date = options['start_date']
|
||||
else:
|
||||
# 最終レコードの翌日を自動算出
|
||||
last = WeatherRecord.objects.order_by('-date').first()
|
||||
if last:
|
||||
start_date = (last.date + datetime.timedelta(days=1)).isoformat()
|
||||
else:
|
||||
start_date = FULL_START
|
||||
self.stdout.write(
|
||||
self.style.WARNING('DB にデータがないため 2016-01-01 から取得します。')
|
||||
)
|
||||
|
||||
if start_date > end_date:
|
||||
self.stdout.write(self.style.SUCCESS('すでに最新の状態です。取得をスキップします。'))
|
||||
return
|
||||
|
||||
self.stdout.write(f'取得期間: {start_date} 〜 {end_date}')
|
||||
|
||||
# Open-Meteo は 1回のリクエストで最大1年分程度が安定。
|
||||
# 長期間の場合は年単位で分割して取得する。
|
||||
start = datetime.date.fromisoformat(start_date)
|
||||
end = datetime.date.fromisoformat(end_date)
|
||||
total_saved = 0
|
||||
|
||||
current = start
|
||||
while current <= end:
|
||||
chunk_end = min(
|
||||
datetime.date(current.year, 12, 31),
|
||||
end
|
||||
)
|
||||
self.stdout.write(f' → {current} 〜 {chunk_end} を取得中...')
|
||||
records = fetch_from_api(current.isoformat(), chunk_end.isoformat())
|
||||
saved = upsert_records(records, stdout=None)
|
||||
total_saved += saved
|
||||
self.stdout.write(f' {saved} 件保存')
|
||||
current = datetime.date(chunk_end.year + 1, 1, 1)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'完了: 合計 {total_saved} 件を保存しました。'))
|
||||
Reference in New Issue
Block a user