- 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>
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""
|
|
気象データを 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} 件を保存しました。'))
|