気象データ基盤を実装

- 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:
Akira
2026-02-28 13:23:09 +09:00
parent b386ee4380
commit 2c515cca6f
19 changed files with 671 additions and 4 deletions

View 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} 件を保存しました。'))